plasalid 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +213 -0
- package/README.md +176 -0
- package/dist/accounts/taxonomy.d.ts +31 -0
- package/dist/accounts/taxonomy.js +189 -0
- package/dist/ai/agent.d.ts +43 -0
- package/dist/ai/agent.js +155 -0
- package/dist/ai/context.d.ts +4 -0
- package/dist/ai/context.js +33 -0
- package/dist/ai/memory.d.ts +14 -0
- package/dist/ai/memory.js +12 -0
- package/dist/ai/provider.d.ts +67 -0
- package/dist/ai/provider.js +5 -0
- package/dist/ai/providers/anthropic.d.ts +5 -0
- package/dist/ai/providers/anthropic.js +49 -0
- package/dist/ai/providers/index.d.ts +2 -0
- package/dist/ai/providers/index.js +12 -0
- package/dist/ai/providers/openai-compat.d.ts +5 -0
- package/dist/ai/providers/openai-compat.js +147 -0
- package/dist/ai/providers/openai.d.ts +5 -0
- package/dist/ai/providers/openai.js +147 -0
- package/dist/ai/redactor.d.ts +2 -0
- package/dist/ai/redactor.js +91 -0
- package/dist/ai/sanitize.d.ts +14 -0
- package/dist/ai/sanitize.js +25 -0
- package/dist/ai/system-prompt.d.ts +13 -0
- package/dist/ai/system-prompt.js +174 -0
- package/dist/ai/thai-taxonomy-hint.d.ts +8 -0
- package/dist/ai/thai-taxonomy-hint.js +22 -0
- package/dist/ai/thinking-phrases.d.ts +7 -0
- package/dist/ai/thinking-phrases.js +15 -0
- package/dist/ai/thinking.d.ts +7 -0
- package/dist/ai/thinking.js +15 -0
- package/dist/ai/tools/common.d.ts +2 -0
- package/dist/ai/tools/common.js +83 -0
- package/dist/ai/tools/index.d.ts +8 -0
- package/dist/ai/tools/index.js +34 -0
- package/dist/ai/tools/ingest.d.ts +2 -0
- package/dist/ai/tools/ingest.js +202 -0
- package/dist/ai/tools/read.d.ts +2 -0
- package/dist/ai/tools/read.js +123 -0
- package/dist/ai/tools/reconcile.d.ts +2 -0
- package/dist/ai/tools/reconcile.js +227 -0
- package/dist/ai/tools/scan.d.ts +2 -0
- package/dist/ai/tools/scan.js +24 -0
- package/dist/ai/tools/types.d.ts +26 -0
- package/dist/ai/tools/types.js +1 -0
- package/dist/ai/tools.d.ts +18 -0
- package/dist/ai/tools.js +402 -0
- package/dist/cli/chat.d.ts +1 -0
- package/dist/cli/chat.js +28 -0
- package/dist/cli/commands/accounts.d.ts +1 -0
- package/dist/cli/commands/accounts.js +86 -0
- package/dist/cli/commands/data.d.ts +1 -0
- package/dist/cli/commands/data.js +28 -0
- package/dist/cli/commands/reconcile.d.ts +2 -0
- package/dist/cli/commands/reconcile.js +15 -0
- package/dist/cli/commands/revert.d.ts +1 -0
- package/dist/cli/commands/revert.js +68 -0
- package/dist/cli/commands/scan.d.ts +4 -0
- package/dist/cli/commands/scan.js +45 -0
- package/dist/cli/commands/status.d.ts +1 -0
- package/dist/cli/commands/status.js +22 -0
- package/dist/cli/commands/transactions.d.ts +8 -0
- package/dist/cli/commands/transactions.js +92 -0
- package/dist/cli/commands/undo.d.ts +1 -0
- package/dist/cli/commands/undo.js +38 -0
- package/dist/cli/commands.d.ts +14 -0
- package/dist/cli/commands.js +196 -0
- package/dist/cli/format.d.ts +8 -0
- package/dist/cli/format.js +109 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +126 -0
- package/dist/cli/ink/ChatApp.d.ts +8 -0
- package/dist/cli/ink/ChatApp.js +94 -0
- package/dist/cli/ink/PromptFrame.d.ts +10 -0
- package/dist/cli/ink/PromptFrame.js +11 -0
- package/dist/cli/ink/TextInput.d.ts +13 -0
- package/dist/cli/ink/TextInput.js +24 -0
- package/dist/cli/ink/hooks/useAgent.d.ts +27 -0
- package/dist/cli/ink/hooks/useAgent.js +65 -0
- package/dist/cli/ink/hooks/useCtrlCExit.d.ts +16 -0
- package/dist/cli/ink/hooks/useCtrlCExit.js +43 -0
- package/dist/cli/ink/hooks/useFooterText.d.ts +2 -0
- package/dist/cli/ink/hooks/useFooterText.js +43 -0
- package/dist/cli/ink/hooks/useTextInput.d.ts +32 -0
- package/dist/cli/ink/hooks/useTextInput.js +356 -0
- package/dist/cli/ink/messages/AssistantMessage.d.ts +3 -0
- package/dist/cli/ink/messages/AssistantMessage.js +6 -0
- package/dist/cli/ink/messages/ErrorMessage.d.ts +4 -0
- package/dist/cli/ink/messages/ErrorMessage.js +6 -0
- package/dist/cli/ink/messages/InterruptedMessage.d.ts +1 -0
- package/dist/cli/ink/messages/InterruptedMessage.js +6 -0
- package/dist/cli/ink/messages/ThinkingLine.d.ts +12 -0
- package/dist/cli/ink/messages/ThinkingLine.js +23 -0
- package/dist/cli/ink/messages/UserMessage.d.ts +4 -0
- package/dist/cli/ink/messages/UserMessage.js +15 -0
- package/dist/cli/ink/mount.d.ts +6 -0
- package/dist/cli/ink/mount.js +12 -0
- package/dist/cli/logo.d.ts +1 -0
- package/dist/cli/logo.js +20 -0
- package/dist/cli/setup.d.ts +2 -0
- package/dist/cli/setup.js +210 -0
- package/dist/cli/ux.d.ts +38 -0
- package/dist/cli/ux.js +104 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.js +66 -0
- package/dist/currency.d.ts +6 -0
- package/dist/currency.js +19 -0
- package/dist/db/connection.d.ts +5 -0
- package/dist/db/connection.js +45 -0
- package/dist/db/encryption.d.ts +11 -0
- package/dist/db/encryption.js +45 -0
- package/dist/db/helpers.d.ts +16 -0
- package/dist/db/helpers.js +45 -0
- package/dist/db/queries/account_balance.d.ts +61 -0
- package/dist/db/queries/account_balance.js +146 -0
- package/dist/db/queries/journal.d.ts +95 -0
- package/dist/db/queries/journal.js +204 -0
- package/dist/db/queries/search.d.ts +7 -0
- package/dist/db/queries/search.js +19 -0
- package/dist/db/schema.d.ts +2 -0
- package/dist/db/schema.js +95 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/parser/pdf.d.ts +14 -0
- package/dist/parser/pdf.js +40 -0
- package/dist/parser/pipeline.d.ts +44 -0
- package/dist/parser/pipeline.js +160 -0
- package/dist/parser/prompts.d.ts +8 -0
- package/dist/parser/prompts.js +20 -0
- package/dist/parser/walker.d.ts +8 -0
- package/dist/parser/walker.js +42 -0
- package/dist/reconciler/pipeline.d.ts +17 -0
- package/dist/reconciler/pipeline.js +45 -0
- package/dist/reconciler/prompts.d.ts +12 -0
- package/dist/reconciler/prompts.js +22 -0
- package/dist/scanner/password-store.d.ts +34 -0
- package/dist/scanner/password-store.js +83 -0
- package/dist/scanner/pdf-unlock.d.ts +17 -0
- package/dist/scanner/pdf-unlock.js +48 -0
- package/dist/scanner/pdf.d.ts +17 -0
- package/dist/scanner/pdf.js +36 -0
- package/dist/scanner/pipeline.d.ts +32 -0
- package/dist/scanner/pipeline.js +137 -0
- package/dist/scanner/prompts.d.ts +8 -0
- package/dist/scanner/prompts.js +20 -0
- package/dist/scanner/state-machine.d.ts +60 -0
- package/dist/scanner/state-machine.js +64 -0
- package/dist/scanner/unlock.d.ts +24 -0
- package/dist/scanner/unlock.js +122 -0
- package/dist/scanner/walker.d.ts +8 -0
- package/dist/scanner/walker.js +42 -0
- package/package.json +65 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { runScan } from "../../scanner/pipeline.js";
|
|
3
|
+
export async function runScanCommand(opts) {
|
|
4
|
+
if (opts.regex !== undefined) {
|
|
5
|
+
try {
|
|
6
|
+
new RegExp(opts.regex, "i");
|
|
7
|
+
}
|
|
8
|
+
catch (err) {
|
|
9
|
+
console.error(chalk.red(`Invalid regex: ${err.message}`));
|
|
10
|
+
process.exitCode = 1;
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const summary = await runScan({ regex: opts.regex, force: opts.force, interactive: true });
|
|
15
|
+
renderScanSummary(summary);
|
|
16
|
+
}
|
|
17
|
+
function renderScanSummary(summary) {
|
|
18
|
+
console.log("");
|
|
19
|
+
console.log(chalk.bold(`Scanned ${summary.total} file(s)`));
|
|
20
|
+
console.log(` ${chalk.green(`${summary.scanned} scanned`)} ` +
|
|
21
|
+
`${chalk.cyan(`${summary.replaced} replaced`)} ` +
|
|
22
|
+
`${chalk.dim(`${summary.skipped} skipped`)} ` +
|
|
23
|
+
`${chalk.yellow(`${summary.needsInput} needs input`)} ` +
|
|
24
|
+
`${chalk.red(`${summary.failed} failed`)}`);
|
|
25
|
+
for (const d of summary.details) {
|
|
26
|
+
const label = d.relPath;
|
|
27
|
+
switch (d.result.status) {
|
|
28
|
+
case "scanned":
|
|
29
|
+
console.log(` ${chalk.green("✓")} ${label}${d.result.summary ? chalk.dim(` — ${d.result.summary}`) : ""}`);
|
|
30
|
+
break;
|
|
31
|
+
case "replaced":
|
|
32
|
+
console.log(` ${chalk.cyan("↻")} ${label} (replaces previous records)`);
|
|
33
|
+
break;
|
|
34
|
+
case "skipped":
|
|
35
|
+
console.log(` ${chalk.dim("•")} ${label} (already scanned)`);
|
|
36
|
+
break;
|
|
37
|
+
case "needs_input":
|
|
38
|
+
console.log(` ${chalk.yellow("!")} ${label} (${d.result.pendingQuestions} pending)`);
|
|
39
|
+
break;
|
|
40
|
+
case "failed":
|
|
41
|
+
console.log(` ${chalk.red("✗")} ${label}${d.result.error ? chalk.dim(` — ${d.result.error}`) : ""}`);
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function showStatus(): void;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { getDb } from "../../db/connection.js";
|
|
3
|
+
import { getNetWorth, getPeriodTotals } from "../../db/queries/account_balance.js";
|
|
4
|
+
import { formatCurrencyAmount } from "../../currency.js";
|
|
5
|
+
function fmt(n) {
|
|
6
|
+
return formatCurrencyAmount(n, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
7
|
+
}
|
|
8
|
+
export function showStatus() {
|
|
9
|
+
const db = getDb();
|
|
10
|
+
const nw = getNetWorth(db);
|
|
11
|
+
console.log(chalk.bold("Net worth: ") + fmt(nw.net_worth));
|
|
12
|
+
console.log(chalk.dim(`Assets ${fmt(nw.assets)} − Liabilities ${fmt(nw.liabilities)}`));
|
|
13
|
+
const now = new Date();
|
|
14
|
+
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
|
|
15
|
+
const today = now.toISOString().slice(0, 10);
|
|
16
|
+
const totals = getPeriodTotals(db, monthStart, today);
|
|
17
|
+
console.log("");
|
|
18
|
+
console.log(chalk.bold(`This month (${monthStart} → ${today})`));
|
|
19
|
+
console.log(` Income: ${fmt(totals.income)}`);
|
|
20
|
+
console.log(` Expenses: ${fmt(totals.expenses)}`);
|
|
21
|
+
console.log(` Net: ${fmt(totals.income - totals.expenses)}`);
|
|
22
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { getDb } from "../../db/connection.js";
|
|
3
|
+
import { listJournalLines } from "../../db/queries/journal.js";
|
|
4
|
+
import { formatCurrencyAmount } from "../../currency.js";
|
|
5
|
+
function fmt(n) {
|
|
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
|
+
}
|
|
13
|
+
function truncateMiddle(s, max) {
|
|
14
|
+
if (s.length <= max)
|
|
15
|
+
return s;
|
|
16
|
+
if (max < 5)
|
|
17
|
+
return s.slice(0, max);
|
|
18
|
+
const keep = max - 1;
|
|
19
|
+
const head = Math.ceil(keep / 2);
|
|
20
|
+
const tail = Math.floor(keep / 2);
|
|
21
|
+
return `${s.slice(0, head)}…${s.slice(s.length - tail)}`;
|
|
22
|
+
}
|
|
23
|
+
const ACCOUNT_CAP = 32;
|
|
24
|
+
const MEMO_CAP = 40;
|
|
25
|
+
function groupByEntry(lines) {
|
|
26
|
+
const groups = [];
|
|
27
|
+
let current = null;
|
|
28
|
+
for (const l of lines) {
|
|
29
|
+
if (!current || current.entry_id !== l.entry_id) {
|
|
30
|
+
current = {
|
|
31
|
+
entry_id: l.entry_id,
|
|
32
|
+
date: l.entry_date ?? "",
|
|
33
|
+
description: l.entry_description ?? "",
|
|
34
|
+
lines: [],
|
|
35
|
+
};
|
|
36
|
+
groups.push(current);
|
|
37
|
+
}
|
|
38
|
+
current.lines.push(l);
|
|
39
|
+
}
|
|
40
|
+
return groups;
|
|
41
|
+
}
|
|
42
|
+
export function showTransactions(opts) {
|
|
43
|
+
const db = getDb();
|
|
44
|
+
const lines = listJournalLines(db, {
|
|
45
|
+
account_id: opts.account,
|
|
46
|
+
from: opts.from,
|
|
47
|
+
to: opts.to,
|
|
48
|
+
q: opts.query,
|
|
49
|
+
limit: opts.limit ?? 100,
|
|
50
|
+
});
|
|
51
|
+
if (lines.length === 0) {
|
|
52
|
+
console.log(chalk.yellow("No journal lines match those filters."));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const truncatedAccount = new Map();
|
|
56
|
+
const truncatedMemo = new Map();
|
|
57
|
+
for (const l of lines) {
|
|
58
|
+
const acct = l.account_name ?? l.account_id;
|
|
59
|
+
truncatedAccount.set(l.id, truncateMiddle(acct, ACCOUNT_CAP));
|
|
60
|
+
if (l.memo)
|
|
61
|
+
truncatedMemo.set(l.id, truncateMiddle(l.memo, MEMO_CAP));
|
|
62
|
+
}
|
|
63
|
+
const accountWidth = Math.max(...lines.map((l) => truncatedAccount.get(l.id).length));
|
|
64
|
+
const amountWidth = Math.max(...lines.map((l) => {
|
|
65
|
+
const side = l.debit > 0 ? "DR" : "CR";
|
|
66
|
+
const amt = l.debit > 0 ? l.debit : l.credit;
|
|
67
|
+
return `${side} ${fmt(amt)}`.length;
|
|
68
|
+
}));
|
|
69
|
+
const cols = process.stdout.columns || 100;
|
|
70
|
+
const descMax = Math.max(20, cols - 14);
|
|
71
|
+
const groups = groupByEntry(lines);
|
|
72
|
+
for (const g of groups) {
|
|
73
|
+
const desc = truncateMiddle(g.description, descMax);
|
|
74
|
+
console.log(`${chalk.dim(g.date)} ${chalk.bold(desc)}`);
|
|
75
|
+
for (const l of g.lines) {
|
|
76
|
+
const acct = truncatedAccount.get(l.id);
|
|
77
|
+
const acctPadded = acct + " ".repeat(accountWidth - acct.length);
|
|
78
|
+
const side = l.debit > 0 ? "DR" : "CR";
|
|
79
|
+
const amt = l.debit > 0 ? l.debit : l.credit;
|
|
80
|
+
const rawAmount = `${side} ${fmt(amt)}`;
|
|
81
|
+
const colored = l.debit > 0 ? chalk.cyan(rawAmount) : chalk.magenta(rawAmount);
|
|
82
|
+
const amountPadded = " ".repeat(amountWidth - visibleLength(colored)) + colored;
|
|
83
|
+
const memo = truncatedMemo.get(l.id);
|
|
84
|
+
const memoStr = memo ? ` ${chalk.dim(memo)}` : "";
|
|
85
|
+
console.log(` ${acctPadded} ${amountPadded}${memoStr}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (groups.length > 1) {
|
|
89
|
+
console.log("");
|
|
90
|
+
console.log(chalk.dim(` ${groups.length} entries · ${lines.length} lines`));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runUndoCommand(regex: string): Promise<void>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import inquirer from "inquirer";
|
|
3
|
+
import { getDb } from "../../db/connection.js";
|
|
4
|
+
import { findUndoMatches, deleteMatches } from "../../scanner/pipeline.js";
|
|
5
|
+
export async function runUndoCommand(regex) {
|
|
6
|
+
if (!regex) {
|
|
7
|
+
console.error(chalk.red("undo requires a regex argument."));
|
|
8
|
+
process.exitCode = 1;
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
let matches;
|
|
12
|
+
try {
|
|
13
|
+
matches = findUndoMatches(getDb(), regex);
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
console.error(chalk.red(`Invalid regex: ${err.message}`));
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (matches.length === 0) {
|
|
21
|
+
console.log(chalk.dim("No scanned files match that regex."));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
console.log(chalk.bold(`undo will delete ${matches.length} file(s) and their journal entries:`));
|
|
25
|
+
for (const m of matches) {
|
|
26
|
+
const when = m.scannedAt ? chalk.dim(` (scanned ${m.scannedAt})`) : "";
|
|
27
|
+
console.log(` • ${m.relPath}${when}`);
|
|
28
|
+
}
|
|
29
|
+
const { proceed } = await inquirer.prompt([
|
|
30
|
+
{ type: "confirm", name: "proceed", message: "Proceed?", default: false },
|
|
31
|
+
]);
|
|
32
|
+
if (!proceed) {
|
|
33
|
+
console.log(chalk.dim("Cancelled."));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const deleted = deleteMatches(getDb(), matches.map(m => m.id));
|
|
37
|
+
console.log(chalk.green(`✓ Removed ${deleted} file(s) and all linked records.`));
|
|
38
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function showAccounts(): void;
|
|
2
|
+
export declare function showStatus(): void;
|
|
3
|
+
export declare function showTransactions(opts: {
|
|
4
|
+
account?: string;
|
|
5
|
+
from?: string;
|
|
6
|
+
to?: string;
|
|
7
|
+
query?: string;
|
|
8
|
+
limit?: number;
|
|
9
|
+
}): void;
|
|
10
|
+
export declare function runScanCommand(opts: {
|
|
11
|
+
regex?: string;
|
|
12
|
+
force?: boolean;
|
|
13
|
+
}): Promise<void>;
|
|
14
|
+
export declare function runUndoCommand(regex: string): Promise<void>;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import inquirer from "inquirer";
|
|
3
|
+
import { getDb } from "../db/connection.js";
|
|
4
|
+
import { getAccountBalances, getNetWorth, getPeriodTotals } from "../db/queries/account_balance.js";
|
|
5
|
+
import { listJournalLines } from "../db/queries/journal.js";
|
|
6
|
+
import { runScan, findUndoMatches, deleteMatches, } from "../scanner/pipeline.js";
|
|
7
|
+
import { formatCurrencyAmount } from "../currency.js";
|
|
8
|
+
function fmt(n) {
|
|
9
|
+
return formatCurrencyAmount(n, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
10
|
+
}
|
|
11
|
+
function fmtSigned(n) {
|
|
12
|
+
const body = formatCurrencyAmount(n, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
13
|
+
return n < 0 ? `-${body}` : body;
|
|
14
|
+
}
|
|
15
|
+
// eslint-disable-next-line no-control-regex
|
|
16
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
17
|
+
function visibleLength(s) {
|
|
18
|
+
return s.replace(ANSI_RE, "").length;
|
|
19
|
+
}
|
|
20
|
+
const TYPE_TAG = {
|
|
21
|
+
asset: "asset",
|
|
22
|
+
liability: "liab",
|
|
23
|
+
income: "income",
|
|
24
|
+
expense: "expense",
|
|
25
|
+
equity: "equity",
|
|
26
|
+
};
|
|
27
|
+
const TYPE_TAG_WIDTH = 8;
|
|
28
|
+
function compactMeta(a) {
|
|
29
|
+
const meta = [];
|
|
30
|
+
if (a.bank_name)
|
|
31
|
+
meta.push(a.bank_name);
|
|
32
|
+
if (a.due_day)
|
|
33
|
+
meta.push(`due ${a.due_day}`);
|
|
34
|
+
if (a.statement_day)
|
|
35
|
+
meta.push(`stmt ${a.statement_day}`);
|
|
36
|
+
if (a.points_balance)
|
|
37
|
+
meta.push(`${a.points_balance.toLocaleString()} pts`);
|
|
38
|
+
if (a.currency && a.currency !== "THB")
|
|
39
|
+
meta.push(a.currency);
|
|
40
|
+
// Show subtype only when there's no other signal yet (e.g. "cash", "salary").
|
|
41
|
+
if (meta.length === 0 && a.subtype)
|
|
42
|
+
meta.push(a.subtype);
|
|
43
|
+
return meta;
|
|
44
|
+
}
|
|
45
|
+
const TYPE_RANK = {
|
|
46
|
+
asset: 0, liability: 1, income: 2, expense: 3, equity: 4,
|
|
47
|
+
};
|
|
48
|
+
export function showAccounts() {
|
|
49
|
+
const db = getDb();
|
|
50
|
+
const accounts = [...getAccountBalances(db)].sort((a, b) => {
|
|
51
|
+
const t = TYPE_RANK[a.type] - TYPE_RANK[b.type];
|
|
52
|
+
return t !== 0 ? t : a.name.localeCompare(b.name);
|
|
53
|
+
});
|
|
54
|
+
if (accounts.length === 0) {
|
|
55
|
+
console.log(chalk.yellow("No accounts yet. Drop PDFs into ~/.plasalid/data/ and run `plasalid scan`."));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const balanceWidth = Math.max(...accounts.map(a => fmtSigned(a.balance).length));
|
|
59
|
+
const nameWidth = Math.max(...accounts.map(a => a.name.length));
|
|
60
|
+
for (const a of accounts) {
|
|
61
|
+
const tag = chalk.dim(TYPE_TAG[a.type].padEnd(TYPE_TAG_WIDTH));
|
|
62
|
+
const name = chalk.bold(a.name) + " ".repeat(nameWidth - a.name.length);
|
|
63
|
+
const rawBalance = fmtSigned(a.balance);
|
|
64
|
+
const coloredBalance = a.balance < 0 ? chalk.red(rawBalance) : rawBalance;
|
|
65
|
+
const paddedBalance = " ".repeat(balanceWidth - visibleLength(coloredBalance)) + coloredBalance;
|
|
66
|
+
const meta = compactMeta(a);
|
|
67
|
+
const metaStr = meta.length ? ` ${chalk.dim(meta.join(" · "))}` : "";
|
|
68
|
+
console.log(` ${tag} ${name} ${paddedBalance}${metaStr}`);
|
|
69
|
+
}
|
|
70
|
+
let assets = 0, liabilities = 0;
|
|
71
|
+
for (const a of accounts) {
|
|
72
|
+
if (a.type === "asset")
|
|
73
|
+
assets += a.balance;
|
|
74
|
+
else if (a.type === "liability")
|
|
75
|
+
liabilities += a.balance;
|
|
76
|
+
}
|
|
77
|
+
const netWorth = assets - liabilities;
|
|
78
|
+
console.log("");
|
|
79
|
+
console.log(" " +
|
|
80
|
+
chalk.dim(`Assets ${fmtSigned(assets)}`) +
|
|
81
|
+
chalk.dim(" · ") +
|
|
82
|
+
chalk.dim(`Liabilities ${fmtSigned(liabilities)}`) +
|
|
83
|
+
chalk.dim(" · ") +
|
|
84
|
+
chalk.bold(`Net worth ${fmtSigned(netWorth)}`));
|
|
85
|
+
}
|
|
86
|
+
export function showStatus() {
|
|
87
|
+
const db = getDb();
|
|
88
|
+
const nw = getNetWorth(db);
|
|
89
|
+
console.log(chalk.bold("Net worth: ") + fmt(nw.net_worth));
|
|
90
|
+
console.log(chalk.dim(`Assets ${fmt(nw.assets)} − Liabilities ${fmt(nw.liabilities)}`));
|
|
91
|
+
const now = new Date();
|
|
92
|
+
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
|
|
93
|
+
const today = now.toISOString().slice(0, 10);
|
|
94
|
+
const totals = getPeriodTotals(db, monthStart, today);
|
|
95
|
+
console.log("");
|
|
96
|
+
console.log(chalk.bold(`This month (${monthStart} → ${today})`));
|
|
97
|
+
console.log(` Income: ${fmt(totals.income)}`);
|
|
98
|
+
console.log(` Expenses: ${fmt(totals.expenses)}`);
|
|
99
|
+
console.log(` Net: ${fmt(totals.income - totals.expenses)}`);
|
|
100
|
+
}
|
|
101
|
+
export function showTransactions(opts) {
|
|
102
|
+
const db = getDb();
|
|
103
|
+
const lines = listJournalLines(db, {
|
|
104
|
+
account_id: opts.account,
|
|
105
|
+
from: opts.from,
|
|
106
|
+
to: opts.to,
|
|
107
|
+
q: opts.query,
|
|
108
|
+
limit: opts.limit ?? 50,
|
|
109
|
+
});
|
|
110
|
+
if (lines.length === 0) {
|
|
111
|
+
console.log(chalk.yellow("No journal lines match those filters."));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
for (const l of lines) {
|
|
115
|
+
const side = l.debit > 0 ? chalk.cyan(`DR ${fmt(l.debit)}`) : chalk.magenta(`CR ${fmt(l.credit)}`);
|
|
116
|
+
const memo = l.memo ? chalk.dim(` — ${l.memo}`) : "";
|
|
117
|
+
console.log(`${chalk.dim(l.entry_date)} ${l.entry_description} ${chalk.dim(`[${l.account_name}]`)} ${side}${memo}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export async function runScanCommand(opts) {
|
|
121
|
+
if (opts.regex !== undefined) {
|
|
122
|
+
try {
|
|
123
|
+
new RegExp(opts.regex, "i");
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
console.error(chalk.red(`Invalid regex: ${err.message}`));
|
|
127
|
+
process.exitCode = 1;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const summary = await runScan({ regex: opts.regex, force: opts.force, interactive: true });
|
|
132
|
+
renderScanSummary(summary);
|
|
133
|
+
}
|
|
134
|
+
export async function runUndoCommand(regex) {
|
|
135
|
+
if (!regex) {
|
|
136
|
+
console.error(chalk.red("undo requires a regex argument."));
|
|
137
|
+
process.exitCode = 1;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
let matches;
|
|
141
|
+
try {
|
|
142
|
+
matches = findUndoMatches(getDb(), regex);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
console.error(chalk.red(`Invalid regex: ${err.message}`));
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (matches.length === 0) {
|
|
150
|
+
console.log(chalk.dim("No scanned files match that regex."));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
console.log(chalk.bold(`undo will delete ${matches.length} file(s) and their journal entries:`));
|
|
154
|
+
for (const m of matches) {
|
|
155
|
+
const when = m.scannedAt ? chalk.dim(` (scanned ${m.scannedAt})`) : "";
|
|
156
|
+
console.log(` • ${m.relPath}${when}`);
|
|
157
|
+
}
|
|
158
|
+
const { proceed } = await inquirer.prompt([
|
|
159
|
+
{ type: "confirm", name: "proceed", message: "Proceed?", default: false },
|
|
160
|
+
]);
|
|
161
|
+
if (!proceed) {
|
|
162
|
+
console.log(chalk.dim("Cancelled."));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const deleted = deleteMatches(getDb(), matches.map(m => m.id));
|
|
166
|
+
console.log(chalk.green(`✓ Removed ${deleted} file(s) and all linked records.`));
|
|
167
|
+
}
|
|
168
|
+
function renderScanSummary(summary) {
|
|
169
|
+
console.log("");
|
|
170
|
+
console.log(chalk.bold(`Scanned ${summary.total} file(s)`));
|
|
171
|
+
console.log(` ${chalk.green(`${summary.scanned} scanned`)} ` +
|
|
172
|
+
`${chalk.cyan(`${summary.replaced} replaced`)} ` +
|
|
173
|
+
`${chalk.dim(`${summary.skipped} skipped`)} ` +
|
|
174
|
+
`${chalk.yellow(`${summary.needsInput} needs input`)} ` +
|
|
175
|
+
`${chalk.red(`${summary.failed} failed`)}`);
|
|
176
|
+
for (const d of summary.details) {
|
|
177
|
+
const label = d.relPath;
|
|
178
|
+
switch (d.result.status) {
|
|
179
|
+
case "scanned":
|
|
180
|
+
console.log(` ${chalk.green("✓")} ${label}${d.result.summary ? chalk.dim(` — ${d.result.summary}`) : ""}`);
|
|
181
|
+
break;
|
|
182
|
+
case "replaced":
|
|
183
|
+
console.log(` ${chalk.cyan("↻")} ${label} (replaces previous records)`);
|
|
184
|
+
break;
|
|
185
|
+
case "skipped":
|
|
186
|
+
console.log(` ${chalk.dim("•")} ${label} (already scanned)`);
|
|
187
|
+
break;
|
|
188
|
+
case "needs_input":
|
|
189
|
+
console.log(` ${chalk.yellow("!")} ${label} (${d.result.pendingQuestions} pending)`);
|
|
190
|
+
break;
|
|
191
|
+
case "failed":
|
|
192
|
+
console.log(` ${chalk.red("✗")} ${label}${d.result.error ? chalk.dim(` — ${d.result.error}`) : ""}`);
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function formatDuration(ms: number): string;
|
|
2
|
+
export declare function formatError(error: any, context?: string): string;
|
|
3
|
+
export declare function banner(): string;
|
|
4
|
+
export declare function helpScreen(commands: {
|
|
5
|
+
name: string;
|
|
6
|
+
desc: string;
|
|
7
|
+
}[]): string;
|
|
8
|
+
export declare function formatResponse(text: string): string;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
export function formatDuration(ms) {
|
|
3
|
+
const s = Math.floor(ms / 1000);
|
|
4
|
+
if (s < 1)
|
|
5
|
+
return "< 1s";
|
|
6
|
+
if (s < 60)
|
|
7
|
+
return `${s}s`;
|
|
8
|
+
const m = Math.floor(s / 60);
|
|
9
|
+
const rem = s % 60;
|
|
10
|
+
if (m < 60)
|
|
11
|
+
return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
|
|
12
|
+
const h = Math.floor(m / 60);
|
|
13
|
+
return `${h}h ${m % 60}m`;
|
|
14
|
+
}
|
|
15
|
+
const ERROR_MAP = {
|
|
16
|
+
"401": {
|
|
17
|
+
msg: "Invalid API key.",
|
|
18
|
+
action: "Run `plasalid setup` to reconfigure.",
|
|
19
|
+
},
|
|
20
|
+
"403": {
|
|
21
|
+
msg: "API key rejected.",
|
|
22
|
+
action: "Run `plasalid setup` to reconfigure.",
|
|
23
|
+
},
|
|
24
|
+
"429": { msg: "Rate limited.", action: "Wait a moment and try again." },
|
|
25
|
+
network: {
|
|
26
|
+
msg: "Could not reach the AI provider.",
|
|
27
|
+
action: "Check your internet connection.",
|
|
28
|
+
},
|
|
29
|
+
decrypt: {
|
|
30
|
+
msg: "Could not decrypt your data.",
|
|
31
|
+
action: "Check your encryption key in `plasalid setup`.",
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
export function formatError(error, context) {
|
|
35
|
+
let key = "unknown";
|
|
36
|
+
if (error?.status)
|
|
37
|
+
key = String(error.status);
|
|
38
|
+
else if (error?.code === "ENOTFOUND" ||
|
|
39
|
+
error?.code === "ECONNREFUSED" ||
|
|
40
|
+
error?.code === "ETIMEDOUT")
|
|
41
|
+
key = "network";
|
|
42
|
+
else if (error?.message?.toLowerCase?.().includes("decrypt"))
|
|
43
|
+
key = "decrypt";
|
|
44
|
+
const mapped = ERROR_MAP[key];
|
|
45
|
+
if (mapped) {
|
|
46
|
+
return `${chalk.red("✗")} ${mapped.msg} ${chalk.dim(mapped.action)}`;
|
|
47
|
+
}
|
|
48
|
+
const safeMsg = error?.message || "Something went wrong.";
|
|
49
|
+
return `${chalk.red("✗")} ${context ? context + ": " : ""}${safeMsg}`;
|
|
50
|
+
}
|
|
51
|
+
export function banner() {
|
|
52
|
+
return chalk.bold("Plasalid") + chalk.dim(" · Talk to your money");
|
|
53
|
+
}
|
|
54
|
+
function stripAnsi(str) {
|
|
55
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
56
|
+
}
|
|
57
|
+
function box(label, lines) {
|
|
58
|
+
const cols = process.stdout.columns || 100;
|
|
59
|
+
const inner = cols - 4;
|
|
60
|
+
const top = `┌─── ${label} ${"─".repeat(Math.max(0, inner - label.length - 5))}┐`;
|
|
61
|
+
const bot = `└${"─".repeat(inner + 2)}┘`;
|
|
62
|
+
const pad = `│${" ".repeat(inner + 2)}│`;
|
|
63
|
+
const body = lines.map((l) => {
|
|
64
|
+
const vis = stripAnsi(l).length;
|
|
65
|
+
return `│ ${l}${" ".repeat(Math.max(0, inner - vis))}│`;
|
|
66
|
+
});
|
|
67
|
+
return [top, pad, ...body, pad, bot].join("\n");
|
|
68
|
+
}
|
|
69
|
+
const DISCLAIMER = "Plasalid is an assistant, not a financial advisor. It only summarizes financial statements — verify amounts against your statements before relying on them.";
|
|
70
|
+
export function helpScreen(commands) {
|
|
71
|
+
const sections = [
|
|
72
|
+
banner(),
|
|
73
|
+
"",
|
|
74
|
+
box("Usage", [
|
|
75
|
+
"plasalid <command> [OPTIONS]",
|
|
76
|
+
"plasalid Start the TUI chat session",
|
|
77
|
+
]),
|
|
78
|
+
"",
|
|
79
|
+
];
|
|
80
|
+
const nameWidth = Math.max(...commands.map((c) => c.name.length));
|
|
81
|
+
const cmdLines = commands.map((c) => `${chalk.white(c.name.padEnd(nameWidth))} ${chalk.dim(c.desc)}`);
|
|
82
|
+
sections.push(box("Commands", cmdLines));
|
|
83
|
+
sections.push("");
|
|
84
|
+
sections.push(box("Options", [
|
|
85
|
+
`${chalk.white("--version".padEnd(nameWidth))} ${chalk.dim("Show the version and exit")}`,
|
|
86
|
+
`${chalk.white("--help".padEnd(nameWidth))} ${chalk.dim("Show this help screen")}`,
|
|
87
|
+
]));
|
|
88
|
+
sections.push("");
|
|
89
|
+
sections.push(chalk.dim(DISCLAIMER));
|
|
90
|
+
return sections.join("\n");
|
|
91
|
+
}
|
|
92
|
+
export function formatResponse(text) {
|
|
93
|
+
return text
|
|
94
|
+
.split("\n")
|
|
95
|
+
.map((line) => {
|
|
96
|
+
if (/^#{1,3}\s+/.test(line)) {
|
|
97
|
+
return chalk.bold(line.replace(/^#{1,3}\s+/, ""));
|
|
98
|
+
}
|
|
99
|
+
line = line.replace(/\*\*(.+?)\*\*/g, (_, t) => chalk.bold(t));
|
|
100
|
+
line = line.replace(/`([^`]+)`/g, (_, t) => chalk.cyan(t));
|
|
101
|
+
line = line.replace(/(\d+(?:\.\d+)?%)/g, (m) => chalk.yellow(m));
|
|
102
|
+
line = line.replace(/[+-]?(?:฿\s?[\d.,]+|[\d.,]+\s?(?:฿|THB|บาท))/g, (m) => (m.startsWith("-") ? chalk.red(m) : chalk.green(m)));
|
|
103
|
+
if (/^\s*[-•]\s/.test(line)) {
|
|
104
|
+
line = line.replace(/^(\s*)([-•])(\s)/, (_, sp, b, s) => sp + chalk.dim(b) + s);
|
|
105
|
+
}
|
|
106
|
+
return line;
|
|
107
|
+
})
|
|
108
|
+
.join("\n");
|
|
109
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { createRequire } from "module";
|
|
4
|
+
import { config, isConfigured } from "../config.js";
|
|
5
|
+
import { helpScreen } from "./format.js";
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const { version } = require("../../package.json");
|
|
8
|
+
const program = new Command();
|
|
9
|
+
function ensureConfigured() {
|
|
10
|
+
if (!isConfigured()) {
|
|
11
|
+
console.error("Plasalid is not configured. Run `plasalid setup` first.");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
program
|
|
16
|
+
.name("plasalid")
|
|
17
|
+
.description("The local-first data layer for personal finance")
|
|
18
|
+
.version(version)
|
|
19
|
+
.addHelpCommand(false)
|
|
20
|
+
.action(async () => {
|
|
21
|
+
if (!isConfigured()) {
|
|
22
|
+
console.log("Plasalid is not configured yet. Running setup...\n");
|
|
23
|
+
const { runSetup } = await import("./setup.js");
|
|
24
|
+
await runSetup();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const { startChat } = await import("./chat.js");
|
|
28
|
+
await startChat();
|
|
29
|
+
});
|
|
30
|
+
program
|
|
31
|
+
.command("setup")
|
|
32
|
+
.description("Configure Plasalid (API key, encryption, data directory)")
|
|
33
|
+
.action(async () => {
|
|
34
|
+
const { runSetup } = await import("./setup.js");
|
|
35
|
+
await runSetup();
|
|
36
|
+
});
|
|
37
|
+
program
|
|
38
|
+
.command("data")
|
|
39
|
+
.description("Open the Plasalid data folder in your OS file explorer")
|
|
40
|
+
.action(async () => {
|
|
41
|
+
const { runDataCommand } = await import("./commands/data.js");
|
|
42
|
+
runDataCommand();
|
|
43
|
+
});
|
|
44
|
+
program
|
|
45
|
+
.command("accounts")
|
|
46
|
+
.description("Show the chart of accounts with balances")
|
|
47
|
+
.action(async () => {
|
|
48
|
+
ensureConfigured();
|
|
49
|
+
const { showAccounts } = await import("./commands/accounts.js");
|
|
50
|
+
showAccounts();
|
|
51
|
+
});
|
|
52
|
+
program
|
|
53
|
+
.command("status")
|
|
54
|
+
.description("Show net worth and this-month income/expense totals")
|
|
55
|
+
.action(async () => {
|
|
56
|
+
ensureConfigured();
|
|
57
|
+
const { showStatus } = await import("./commands/status.js");
|
|
58
|
+
showStatus();
|
|
59
|
+
});
|
|
60
|
+
program
|
|
61
|
+
.command("transactions")
|
|
62
|
+
.description("List journal lines")
|
|
63
|
+
.option("-a, --account <id>", "Filter by account id")
|
|
64
|
+
.option("--from <date>", "From date YYYY-MM-DD")
|
|
65
|
+
.option("--to <date>", "To date YYYY-MM-DD")
|
|
66
|
+
.option("-q, --query <text>", "Free-text search on description / memo")
|
|
67
|
+
.option("-n, --limit <number>", "Max results", "100")
|
|
68
|
+
.action(async (opts) => {
|
|
69
|
+
ensureConfigured();
|
|
70
|
+
const { showTransactions } = await import("./commands/transactions.js");
|
|
71
|
+
showTransactions({
|
|
72
|
+
account: opts.account,
|
|
73
|
+
from: opts.from,
|
|
74
|
+
to: opts.to,
|
|
75
|
+
query: opts.query,
|
|
76
|
+
limit: Number(opts.limit),
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
program
|
|
80
|
+
.command("scan [regex]")
|
|
81
|
+
.description("Scan every new PDF under ~/.plasalid/data (optionally filtered by regex)")
|
|
82
|
+
.option("-f, --force", "Re-scan matching files (cascade-deletes prior records)")
|
|
83
|
+
.action(async (regex, opts) => {
|
|
84
|
+
ensureConfigured();
|
|
85
|
+
const { runScanCommand } = await import("./commands/scan.js");
|
|
86
|
+
await runScanCommand({ regex, force: !!opts.force });
|
|
87
|
+
});
|
|
88
|
+
program
|
|
89
|
+
.command("reconcile")
|
|
90
|
+
.description("Revisit the existing journal: find duplicate entries, similar accounts, and unused accounts; apply fixes after user confirmation")
|
|
91
|
+
.option("-a, --account <id>", "Limit reconciliation to a single account")
|
|
92
|
+
.option("--from <date>", "Only consider entries on or after this date (YYYY-MM-DD)")
|
|
93
|
+
.option("--to <date>", "Only consider entries on or before this date (YYYY-MM-DD)")
|
|
94
|
+
.option("-d, --dry-run", "Surface findings without applying any change")
|
|
95
|
+
.action(async (opts) => {
|
|
96
|
+
ensureConfigured();
|
|
97
|
+
const { runReconcileCommand } = await import("./commands/reconcile.js");
|
|
98
|
+
await runReconcileCommand({
|
|
99
|
+
accountId: opts.account,
|
|
100
|
+
from: opts.from,
|
|
101
|
+
to: opts.to,
|
|
102
|
+
dryRun: !!opts.dryRun,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
program
|
|
106
|
+
.command("revert <regex>")
|
|
107
|
+
.description("Delete scanned files matching <regex> and all their journal entries")
|
|
108
|
+
.action(async (regex) => {
|
|
109
|
+
ensureConfigured();
|
|
110
|
+
const { runRevertCommand } = await import("./commands/revert.js");
|
|
111
|
+
await runRevertCommand(regex);
|
|
112
|
+
});
|
|
113
|
+
program.configureHelp({
|
|
114
|
+
formatHelp: () => helpScreen([
|
|
115
|
+
{ name: "setup", desc: "Configure Plasalid (API key, encryption, data dir)" },
|
|
116
|
+
{ name: "data", desc: "Open the data folder in your OS file explorer" },
|
|
117
|
+
{ name: "accounts", desc: "Show the chart of accounts with balances" },
|
|
118
|
+
{ name: "status", desc: "Show net worth and this-month totals" },
|
|
119
|
+
{ name: "transactions", desc: "List journal lines (filter by account/date/text)" },
|
|
120
|
+
{ name: "scan", desc: "Scan new PDFs (optionally by regex; --force to re-scan)" },
|
|
121
|
+
{ name: "reconcile", desc: "Review and fix existing journal entries / accounts" },
|
|
122
|
+
{ name: "revert", desc: "Delete scanned files matching <regex> and their journal entries" },
|
|
123
|
+
]),
|
|
124
|
+
});
|
|
125
|
+
void config; // keep config import live so dotenv loads
|
|
126
|
+
program.parse();
|