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.
Files changed (81) hide show
  1. package/README.md +33 -43
  2. package/dist/accounts/taxonomy.d.ts +1 -1
  3. package/dist/accounts/taxonomy.js +2 -2
  4. package/dist/ai/agent.d.ts +19 -5
  5. package/dist/ai/agent.js +26 -6
  6. package/dist/ai/memory.d.ts +14 -5
  7. package/dist/ai/memory.js +12 -0
  8. package/dist/ai/personas.d.ts +11 -0
  9. package/dist/ai/personas.js +193 -0
  10. package/dist/ai/prompt-sections.d.ts +49 -0
  11. package/dist/ai/prompt-sections.js +107 -0
  12. package/dist/ai/system-prompt.d.ts +14 -3
  13. package/dist/ai/system-prompt.js +59 -165
  14. package/dist/ai/thinking.js +1 -1
  15. package/dist/ai/tools/common.js +2 -5
  16. package/dist/ai/tools/index.js +32 -7
  17. package/dist/ai/tools/ingest.d.ts +3 -1
  18. package/dist/ai/tools/ingest.js +372 -124
  19. package/dist/ai/tools/merchants.d.ts +2 -0
  20. package/dist/ai/tools/merchants.js +117 -0
  21. package/dist/ai/tools/read.js +57 -24
  22. package/dist/ai/tools/record.d.ts +2 -0
  23. package/dist/ai/tools/record.js +188 -0
  24. package/dist/ai/tools/review.d.ts +2 -0
  25. package/dist/ai/tools/review.js +359 -0
  26. package/dist/ai/tools/scan.js +5 -3
  27. package/dist/ai/tools/types.d.ts +33 -4
  28. package/dist/cli/commands/accounts.js +33 -25
  29. package/dist/cli/commands/record.d.ts +4 -0
  30. package/dist/cli/commands/record.js +119 -0
  31. package/dist/cli/commands/revert.js +1 -1
  32. package/dist/cli/commands/review.d.ts +2 -0
  33. package/dist/cli/commands/review.js +15 -0
  34. package/dist/cli/commands/scan.d.ts +4 -2
  35. package/dist/cli/commands/scan.js +143 -19
  36. package/dist/cli/commands/status.js +6 -9
  37. package/dist/cli/commands/transactions.js +36 -41
  38. package/dist/cli/format.d.ts +2 -0
  39. package/dist/cli/format.js +7 -2
  40. package/dist/cli/index.js +28 -13
  41. package/dist/cli/ink/scan_dashboard.d.ts +38 -0
  42. package/dist/cli/ink/scan_dashboard.js +62 -0
  43. package/dist/cli/setup.d.ts +0 -1
  44. package/dist/cli/setup.js +2 -8
  45. package/dist/cli/ux.d.ts +2 -1
  46. package/dist/cli/ux.js +36 -2
  47. package/dist/currency.d.ts +3 -0
  48. package/dist/currency.js +12 -1
  49. package/dist/db/queries/account_balance.d.ts +84 -4
  50. package/dist/db/queries/account_balance.js +239 -20
  51. package/dist/db/queries/action_log.d.ts +29 -0
  52. package/dist/db/queries/action_log.js +27 -0
  53. package/dist/db/queries/concerns.d.ts +50 -0
  54. package/dist/db/queries/concerns.js +91 -0
  55. package/dist/db/queries/journal.d.ts +75 -8
  56. package/dist/db/queries/journal.js +131 -19
  57. package/dist/db/queries/merchants.d.ts +42 -0
  58. package/dist/db/queries/merchants.js +120 -0
  59. package/dist/db/queries/recurrences.d.ts +33 -0
  60. package/dist/db/queries/recurrences.js +128 -0
  61. package/dist/db/queries/search.d.ts +5 -4
  62. package/dist/db/queries/search.js +16 -12
  63. package/dist/db/queries/transactions.d.ts +167 -0
  64. package/dist/db/queries/transactions.js +320 -0
  65. package/dist/db/schema.js +74 -9
  66. package/dist/reviewer/pipeline.d.ts +18 -0
  67. package/dist/reviewer/pipeline.js +46 -0
  68. package/dist/reviewer/prompts.d.ts +12 -0
  69. package/dist/reviewer/prompts.js +22 -0
  70. package/dist/scanner/account_mutex.d.ts +1 -0
  71. package/dist/scanner/account_mutex.js +16 -0
  72. package/dist/scanner/buffer.d.ts +51 -0
  73. package/dist/scanner/buffer.js +63 -0
  74. package/dist/scanner/concurrency.d.ts +14 -0
  75. package/dist/scanner/concurrency.js +31 -0
  76. package/dist/scanner/decrypt_queue.d.ts +57 -0
  77. package/dist/scanner/decrypt_queue.js +96 -0
  78. package/dist/scanner/pipeline.d.ts +47 -18
  79. package/dist/scanner/pipeline.js +247 -97
  80. package/dist/scanner/prompts.js +3 -3
  81. package/package.json +2 -2
@@ -1,4 +1,6 @@
1
- export declare function runScanCommand(opts: {
1
+ export interface ScanCommandOptions {
2
2
  regex?: string;
3
3
  force?: boolean;
4
- }): Promise<void>;
4
+ parallel?: number;
5
+ }
6
+ export declare function runScanCommand(opts: ScanCommandOptions): Promise<void>;
@@ -11,35 +11,159 @@ export async function runScanCommand(opts) {
11
11
  return;
12
12
  }
13
13
  }
14
- const summary = await runScan({ regex: opts.regex, force: opts.force, interactive: true });
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
- 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`)}`);
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.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)`);
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
- case "skipped":
35
- console.log(` ${chalk.dim("")} ${label} (already scanned)`);
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
- case "needs_input":
38
- console.log(` ${chalk.yellow("!")} ${label} (${d.result.pendingQuestions} pending)`);
151
+ }
152
+ case "skipped": {
153
+ console.log(` ${chalk.dim("•")} ${label} ${chalk.dim("(already scanned)")}`);
39
154
  break;
40
- case "failed":
41
- console.log(` ${chalk.red("")} ${label}${d.result.error ? chalk.dim(` — ${d.result.error}`) : ""}`);
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 { formatCurrencyAmount } from "../../currency.js";
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: ") + fmt(nw.net_worth));
12
- console.log(chalk.dim(`Assets ${fmt(nw.assets)} − Liabilities ${fmt(nw.liabilities)}`));
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: ${fmt(totals.income)}`);
20
- console.log(` Expenses: ${fmt(totals.expenses)}`);
21
- console.log(` Net: ${fmt(totals.income - totals.expenses)}`);
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 { 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
- }
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 groupByEntry(lines) {
18
+ function groupByTransaction(postings) {
26
19
  const groups = [];
27
20
  let current = null;
28
- for (const l of lines) {
29
- if (!current || current.entry_id !== l.entry_id) {
21
+ for (const p of postings) {
22
+ if (!current || current.transaction_id !== p.transaction_id) {
30
23
  current = {
31
- entry_id: l.entry_id,
32
- date: l.entry_date ?? "",
33
- description: l.entry_description ?? "",
34
- lines: [],
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.lines.push(l);
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 lines = listJournalLines(db, {
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 (lines.length === 0) {
52
- console.log(chalk.yellow("No journal lines match those filters."));
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 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));
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(...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;
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 = groupByEntry(lines);
65
+ const groups = groupByTransaction(postings);
72
66
  for (const g of groups) {
73
67
  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);
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 = 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);
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(l.id);
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} entries · ${lines.length} lines`));
85
+ console.log(chalk.dim(` ${groups.length} transactions · ${postings.length} postings`));
91
86
  }
92
87
  }
@@ -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;
@@ -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(" · Talk to your money");
57
+ return chalk.bold("Plasalid") + chalk.dim(" · The Harness Layer for Personal Finance");
53
58
  }
54
59
  function stripAnsi(str) {
55
- return str.replace(/\x1b\[[0-9;]*m/g, "");
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 local-first data layer for personal finance")
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 journal lines")
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("reconcile")
104
- .description("Revisit the existing journal: find duplicate entries, similar accounts, and unused accounts; apply fixes after user confirmation")
105
- .option("-a, --account <id>", "Limit reconciliation to a single account")
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 { runReconcileCommand } = await import("./commands/reconcile.js");
112
- await runReconcileCommand({
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 journal entries")
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 journal lines (filter by account/date/text)",
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: "reconcile",
149
- desc: "Review and fix existing journal entries / accounts",
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 journal entries",
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
+ }
@@ -1,2 +1 @@
1
1
  export declare function runSetup(): Promise<void>;
2
- export declare function ensureConfigured(): void;
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, isConfigured, getPlasalidDir, getDataDir, } from "../config.js";
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 chat with your financial data.`);
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`.