plasalid 0.5.7 → 0.6.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 (97) hide show
  1. package/README.md +9 -9
  2. package/dist/accounts/taxonomy.d.ts +1 -1
  3. package/dist/accounts/taxonomy.js +2 -2
  4. package/dist/ai/agent.d.ts +8 -9
  5. package/dist/ai/agent.js +21 -20
  6. package/dist/ai/errors.d.ts +16 -0
  7. package/dist/ai/errors.js +47 -0
  8. package/dist/ai/personas.d.ts +1 -1
  9. package/dist/ai/personas.js +69 -66
  10. package/dist/ai/prompt-sections.d.ts +4 -5
  11. package/dist/ai/prompt-sections.js +11 -11
  12. package/dist/ai/providers/anthropic.js +10 -4
  13. package/dist/ai/providers/openai.js +70 -56
  14. package/dist/ai/redactor.js +77 -51
  15. package/dist/ai/system-prompt.d.ts +2 -3
  16. package/dist/ai/system-prompt.js +5 -5
  17. package/dist/ai/tools/common.js +13 -5
  18. package/dist/ai/tools/index.js +15 -15
  19. package/dist/ai/tools/ingest.d.ts +2 -2
  20. package/dist/ai/tools/ingest.js +210 -87
  21. package/dist/ai/tools/merchants.js +27 -12
  22. package/dist/ai/tools/read.js +36 -20
  23. package/dist/ai/tools/record.js +79 -19
  24. package/dist/ai/tools/resolve.d.ts +2 -0
  25. package/dist/ai/tools/resolve.js +195 -0
  26. package/dist/ai/tools/types.d.ts +5 -7
  27. package/dist/cli/commands/accounts.js +2 -2
  28. package/dist/cli/commands/record.js +4 -2
  29. package/dist/cli/commands/resolve.d.ts +2 -0
  30. package/dist/cli/commands/resolve.js +13 -0
  31. package/dist/cli/commands/scan.js +18 -22
  32. package/dist/cli/commands/status.js +4 -2
  33. package/dist/cli/index.js +9 -9
  34. package/dist/cli/ink/hooks/useFooterText.js +1 -1
  35. package/dist/cli/ink/hooks/useTextInput.js +60 -69
  36. package/dist/cli/ink/scan_dashboard.d.ts +2 -2
  37. package/dist/cli/ink/scan_dashboard.js +3 -3
  38. package/dist/cli/setup.js +6 -3
  39. package/dist/cli/ux.js +1 -1
  40. package/dist/db/queries/account-balance.d.ts +140 -0
  41. package/dist/db/queries/account-balance.js +355 -0
  42. package/dist/db/queries/account_balance.d.ts +0 -1
  43. package/dist/db/queries/account_balance.js +0 -10
  44. package/dist/db/queries/action-log.d.ts +29 -0
  45. package/dist/db/queries/action-log.js +27 -0
  46. package/dist/db/queries/action_log.d.ts +1 -1
  47. package/dist/db/queries/concerns.d.ts +10 -0
  48. package/dist/db/queries/concerns.js +21 -0
  49. package/dist/db/queries/transactions.d.ts +3 -22
  50. package/dist/db/queries/transactions.js +4 -5
  51. package/dist/db/queries/unknowns.d.ts +62 -0
  52. package/dist/db/queries/unknowns.js +114 -0
  53. package/dist/db/schema.js +3 -3
  54. package/dist/resolver/pipeline.d.ts +16 -0
  55. package/dist/resolver/pipeline.js +38 -0
  56. package/dist/resolver/prompts.d.ts +8 -0
  57. package/dist/resolver/prompts.js +26 -0
  58. package/dist/scanner/account-mutex.d.ts +1 -0
  59. package/dist/scanner/account-mutex.js +16 -0
  60. package/dist/scanner/buffer.d.ts +10 -10
  61. package/dist/scanner/buffer.js +15 -15
  62. package/dist/scanner/concurrency.d.ts +10 -7
  63. package/dist/scanner/concurrency.js +3 -16
  64. package/dist/scanner/decrypt-queue.d.ts +57 -0
  65. package/dist/scanner/decrypt-queue.js +114 -0
  66. package/dist/scanner/decrypt_queue.js +56 -38
  67. package/dist/scanner/detectors/correlations.d.ts +2 -0
  68. package/dist/scanner/detectors/correlations.js +51 -0
  69. package/dist/scanner/detectors/duplicates.d.ts +2 -0
  70. package/dist/scanner/detectors/duplicates.js +75 -0
  71. package/dist/scanner/detectors/index.d.ts +18 -0
  72. package/dist/scanner/detectors/index.js +39 -0
  73. package/dist/scanner/detectors/recurrences.d.ts +2 -0
  74. package/dist/scanner/detectors/recurrences.js +49 -0
  75. package/dist/scanner/detectors/similar_accounts.d.ts +2 -0
  76. package/dist/scanner/detectors/similar_accounts.js +64 -0
  77. package/dist/scanner/detectors/similarities.d.ts +2 -0
  78. package/dist/scanner/detectors/similarities.js +73 -0
  79. package/dist/scanner/detectors/types.d.ts +16 -0
  80. package/dist/scanner/detectors/types.js +1 -0
  81. package/dist/scanner/inspectors/correlations.d.ts +2 -0
  82. package/dist/scanner/inspectors/correlations.js +47 -0
  83. package/dist/scanner/inspectors/duplicates.d.ts +2 -0
  84. package/dist/scanner/inspectors/duplicates.js +75 -0
  85. package/dist/scanner/inspectors/index.d.ts +19 -0
  86. package/dist/scanner/inspectors/index.js +39 -0
  87. package/dist/scanner/inspectors/recurrences.d.ts +2 -0
  88. package/dist/scanner/inspectors/recurrences.js +49 -0
  89. package/dist/scanner/inspectors/similarities.d.ts +2 -0
  90. package/dist/scanner/inspectors/similarities.js +73 -0
  91. package/dist/scanner/inspectors/types.d.ts +16 -0
  92. package/dist/scanner/inspectors/types.js +1 -0
  93. package/dist/scanner/pdf-unlock.js +3 -1
  94. package/dist/scanner/pipeline.d.ts +6 -4
  95. package/dist/scanner/pipeline.js +63 -102
  96. package/dist/scanner/prompts.js +2 -2
  97. package/package.json +2 -1
@@ -59,20 +59,10 @@ async function buildInkEvents(parallel) {
59
59
  fileName: e.fileName,
60
60
  status: e.status,
61
61
  transactions: e.transactions,
62
- concerns: e.concerns,
62
+ unknowns: e.unknowns,
63
63
  error: e.error,
64
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
65
  committing: () => {
75
- // In case correlating fired with 0 pairs, ink may still be mounted; unmount now.
76
66
  if (inkInstance) {
77
67
  inkInstance.unmount();
78
68
  inkInstance = null;
@@ -80,6 +70,11 @@ async function buildInkEvents(parallel) {
80
70
  if (mountedFiles > 0)
81
71
  console.log(chalk.dim("Committing..."));
82
72
  },
73
+ inspecting: (result) => {
74
+ if (mountedFiles > 0 && result.total > 0) {
75
+ console.log(chalk.dim(`Inspectors flagged ${result.total} unknown(s).`));
76
+ }
77
+ },
83
78
  };
84
79
  }
85
80
  /** Plain-text progress (non-TTY or fallback) */
@@ -112,19 +107,20 @@ function buildPlainTextEvents() {
112
107
  scanEnd: (e) => {
113
108
  lastStepByFile.delete(e.fileName);
114
109
  if (e.status === "scanned") {
115
- console.log(`${chalk.green("✓")} ${e.fileName} ${chalk.dim(`(${e.transactions} transactions, ${e.concerns} concerns)`)}`);
110
+ console.log(`${chalk.green("✓")} ${e.fileName} ${chalk.dim(`(${e.transactions} transactions, ${e.unknowns} unknowns)`)}`);
116
111
  }
117
112
  else {
118
113
  console.log(`${chalk.red("✗")} ${e.fileName} ${chalk.dim(`— ${e.error ?? "failed"}`)}`);
119
114
  }
120
115
  },
121
- correlating: (pairs) => {
122
- if (pairs > 0)
123
- console.log(chalk.dim(`Correlating across files... ${pairs} pair(s) flagged.`));
124
- },
125
116
  committing: () => {
126
117
  console.log(chalk.dim("Committing..."));
127
118
  },
119
+ inspecting: (result) => {
120
+ if (result.total > 0) {
121
+ console.log(chalk.dim(`Inspectors flagged ${result.total} unknown(s).`));
122
+ }
123
+ },
128
124
  };
129
125
  }
130
126
  /** Terse summary */
@@ -133,19 +129,19 @@ function renderScanSummary(summary) {
133
129
  const headline = `Scanned ${summary.total} file(s) — ` +
134
130
  `${summary.scanned + summary.replaced} ok, ` +
135
131
  `${summary.failed} failed, ` +
136
- `${summary.concerns} concern${summary.concerns === 1 ? "" : "s"} flagged`;
132
+ `${summary.unknowns} unknown${summary.unknowns === 1 ? "" : "s"} flagged`;
137
133
  console.log(chalk.bold(headline));
138
134
  console.log("");
139
135
  for (const d of summary.details) {
140
136
  const label = d.relPath;
141
137
  switch (d.status) {
142
138
  case "scanned": {
143
- const tag = chalk.dim(`${d.transactions} transactions${d.concerns > 0 ? ` · ${d.concerns} concerns` : ""}`);
139
+ const tag = chalk.dim(`${d.transactions} transactions${d.unknowns > 0 ? ` · ${d.unknowns} unknowns` : ""}`);
144
140
  console.log(` ${chalk.green("✓")} ${label} ${tag}`);
145
141
  break;
146
142
  }
147
143
  case "replaced": {
148
- const tag = chalk.dim(`${d.transactions} transactions${d.concerns > 0 ? ` · ${d.concerns} concerns` : ""} (replaces prior)`);
144
+ const tag = chalk.dim(`${d.transactions} transactions${d.unknowns > 0 ? ` · ${d.unknowns} unknowns` : ""} (replaces prior)`);
149
145
  console.log(` ${chalk.cyan("↻")} ${label} ${tag}`);
150
146
  break;
151
147
  }
@@ -162,8 +158,8 @@ function renderScanSummary(summary) {
162
158
  const newlyProcessed = summary.scanned + summary.replaced;
163
159
  if (newlyProcessed > 0) {
164
160
  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.")}`);
161
+ console.log(`${chalk.dim("Next:")} ${chalk.cyan("plasalid resolve")}${chalk.dim(summary.unknowns > 0
162
+ ? " — to walk every open unknown and apply your decision."
163
+ : " — no unknowns surfaced this run; nothing to do.")}`);
168
164
  }
169
165
  }
@@ -1,6 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { getDb } from "../../db/connection.js";
3
- import { getNetWorth, getPeriodTotals } from "../../db/queries/account_balance.js";
3
+ import { getNetWorth, getPeriodTotals, } from "../../db/queries/account-balance.js";
4
4
  import { formatAmount } from "../../currency.js";
5
5
  export function showStatus() {
6
6
  const db = getDb();
@@ -8,7 +8,9 @@ export function showStatus() {
8
8
  console.log(chalk.bold("Net worth: ") + formatAmount(nw.net_worth));
9
9
  console.log(chalk.dim(`Assets ${formatAmount(nw.assets)} − Liabilities ${formatAmount(nw.liabilities)}`));
10
10
  const now = new Date();
11
- const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
11
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
12
+ .toISOString()
13
+ .slice(0, 10);
12
14
  const today = now.toISOString().slice(0, 10);
13
15
  const totals = getPeriodTotals(db, monthStart, today);
14
16
  console.log("");
package/dist/cli/index.js CHANGED
@@ -115,20 +115,20 @@ program
115
115
  await runScanCommand({ regex: regexes[0], force: !!opts.force, parallel });
116
116
  });
117
117
  program
118
- .command("review")
119
- .description("See the whole picture connect related transactions across statements, surface recurring patterns, and clear up anything that's still in question.")
120
- .option("-a, --account <id>", "Limit review to a single account")
118
+ .command("resolve")
119
+ .description("Walk every open unknown from the last scan one at a time and apply your decision (categorize, merge duplicates, link recurrences, skip).")
120
+ .option("-a, --account <id>", "Limit to unknowns attached to a single account")
121
121
  .option("--from <date>", "Only consider entries on or after this date (YYYY-MM-DD)")
122
122
  .option("--to <date>", "Only consider entries on or before this date (YYYY-MM-DD)")
123
- .option("-d, --dry-run", "Surface findings without applying any change")
123
+ .option("-k, --kind <kind>", "Filter by unknown kind (uncategorized_expense, duplicate, correlation, recurrence_candidate, similar_accounts)")
124
124
  .action(async (opts) => {
125
125
  ensureConfigured();
126
- const { runReviewCommand } = await import("./commands/review.js");
127
- await runReviewCommand({
126
+ const { runResolveCommand } = await import("./commands/resolve.js");
127
+ await runResolveCommand({
128
128
  accountId: opts.account,
129
129
  from: opts.from,
130
130
  to: opts.to,
131
- dryRun: !!opts.dryRun,
131
+ kind: opts.kind,
132
132
  });
133
133
  });
134
134
  program
@@ -164,8 +164,8 @@ program.configureHelp({
164
164
  desc: "Scan new PDFs (optionally by regex; --force to re-scan)",
165
165
  },
166
166
  {
167
- name: "review",
168
- desc: "Cleanup uncategorized, connect duplicates, learn recurring patterns",
167
+ name: "resolve",
168
+ desc: "Walk open unknowns one at a time and apply your decision",
169
169
  },
170
170
  {
171
171
  name: "revert",
@@ -12,7 +12,7 @@ const HINTS = [
12
12
  "try: list my subscriptions",
13
13
  "try: how much liquid cash do I have?",
14
14
  "try: net worth trend this year?",
15
- "try: open concerns from last scan?",
15
+ "try: open unknowns from last scan?",
16
16
  ];
17
17
  export function useFooterText(db) {
18
18
  const [tick, setTick] = useState(0);
@@ -13,9 +13,6 @@ const ENTER = 13;
13
13
  const BACKSPACE = 127;
14
14
  const BACKSPACE_ALT = 8;
15
15
  const ESC = 27;
16
- function cloneBuf(b) {
17
- return { lines: [...b.lines], row: b.row, col: b.col };
18
- }
19
16
  function wordLeft(line, col) {
20
17
  let p = col;
21
18
  while (p > 0 && line[p - 1] === " ")
@@ -58,6 +55,22 @@ function insertText(buf, text) {
58
55
  col: last.length,
59
56
  };
60
57
  }
58
+ function moveToBol(buf) {
59
+ return { ...buf, col: 0 };
60
+ }
61
+ function moveToEol(buf) {
62
+ return { ...buf, col: buf.lines[buf.row].length };
63
+ }
64
+ function killToEol(buf) {
65
+ const lines = [...buf.lines];
66
+ lines[buf.row] = lines[buf.row].slice(0, buf.col);
67
+ return { lines, row: buf.row, col: buf.col };
68
+ }
69
+ function killToBol(buf) {
70
+ const lines = [...buf.lines];
71
+ lines[buf.row] = lines[buf.row].slice(buf.col);
72
+ return { lines, row: buf.row, col: 0 };
73
+ }
61
74
  function backspace(buf) {
62
75
  if (buf.col > 0) {
63
76
  const lines = [...buf.lines];
@@ -128,6 +141,43 @@ function moveWordRight(buf) {
128
141
  function toString(buf) {
129
142
  return buf.lines.join("\n");
130
143
  }
144
+ /** Pure mutators dispatched by single keycode. Side-effecting keys (Ctrl+C, Ctrl+D,
145
+ * Enter, ESC) stay inline in handleChunk because they call host callbacks or open
146
+ * a sub-state-machine for escape sequences. */
147
+ const CTRL_HANDLERS = {
148
+ [CTRL_A]: moveToBol,
149
+ [CTRL_E]: moveToEol,
150
+ [CTRL_K]: killToEol,
151
+ [CTRL_U]: killToBol,
152
+ [CTRL_W]: deleteWordLeft,
153
+ [BACKSPACE]: backspace,
154
+ [BACKSPACE_ALT]: backspace,
155
+ };
156
+ /** CSI sequences: ESC [ ... <final>. `wordMod` runs when the parameter is one of
157
+ * the word-step modifiers (Option/Ctrl/Cmd) — `1;3`, `1;5`, `1;9`. */
158
+ const CSI_HANDLERS = {
159
+ D: { plain: moveLeft, wordMod: moveWordLeft },
160
+ C: { plain: moveRight, wordMod: moveWordRight },
161
+ A: { plain: moveUp, wordMod: moveUp },
162
+ B: { plain: moveDown, wordMod: moveDown },
163
+ H: { plain: moveToBol, wordMod: moveToBol },
164
+ F: { plain: moveToEol, wordMod: moveToEol },
165
+ };
166
+ /** Kitty keyboard protocol: ESC [ codepoint ; modifier u */
167
+ function handleKittyKey(seq, apply) {
168
+ const parts = seq.split(";");
169
+ const codepoint = parseInt(parts[0], 10);
170
+ const mod = parts.length > 1 ? parseInt(parts[1], 10) : 1;
171
+ const hasShift = ((mod - 1) & 1) !== 0;
172
+ const hasCtrl = ((mod - 1) & 4) !== 0;
173
+ const hasCmd = ((mod - 1) & 8) !== 0;
174
+ if (codepoint === 13 && hasShift) {
175
+ apply((b) => insertText(b, "\n"));
176
+ }
177
+ else if (codepoint === 127 && (hasCmd || hasCtrl)) {
178
+ apply(killToBol);
179
+ }
180
+ }
131
181
  /**
132
182
  * Raw-stdin driven keystroke state machine that owns a multiline buffer and
133
183
  * exposes its current state plus reset/insert helpers. Purely stateful — Ink
@@ -221,42 +271,15 @@ export function useTextInput(opts) {
221
271
  }
222
272
  continue;
223
273
  }
224
- if (code === CTRL_A) {
225
- apply(b => ({ ...b, col: 0 }));
226
- continue;
227
- }
228
- if (code === CTRL_E) {
229
- apply(b => ({ ...b, col: b.lines[b.row].length }));
230
- continue;
231
- }
232
- if (code === CTRL_K) {
233
- apply(b => {
234
- const lines = [...b.lines];
235
- lines[b.row] = lines[b.row].slice(0, b.col);
236
- return { lines, row: b.row, col: b.col };
237
- });
238
- continue;
239
- }
240
- if (code === CTRL_U) {
241
- apply(b => {
242
- const lines = [...b.lines];
243
- lines[b.row] = lines[b.row].slice(b.col);
244
- return { lines, row: b.row, col: 0 };
245
- });
246
- continue;
247
- }
248
- if (code === CTRL_W) {
249
- apply(deleteWordLeft);
250
- continue;
251
- }
252
274
  if (code === ENTER) {
253
275
  optsRef.current.onSubmit(toString(bufferRef.current));
254
276
  setBuffer(EMPTY_BUFFER);
255
277
  optsRef.current.onChange?.(EMPTY_BUFFER);
256
278
  continue;
257
279
  }
258
- if (code === BACKSPACE || code === BACKSPACE_ALT) {
259
- apply(backspace);
280
+ const ctrlHandler = CTRL_HANDLERS[code];
281
+ if (ctrlHandler) {
282
+ apply(ctrlHandler);
260
283
  continue;
261
284
  }
262
285
  if (code === ESC) {
@@ -288,44 +311,12 @@ export function useTextInput(opts) {
288
311
  if (i < chunk.length) {
289
312
  const final = chunk[i];
290
313
  const isWordMod = seq === "1;3" || seq === "1;5" || seq === "1;9";
291
- if (final === "D") {
292
- apply(isWordMod ? moveWordLeft : moveLeft);
293
- }
294
- else if (final === "C") {
295
- apply(isWordMod ? moveWordRight : moveRight);
296
- }
297
- else if (final === "A") {
298
- apply(moveUp);
299
- }
300
- else if (final === "B") {
301
- apply(moveDown);
302
- }
303
- else if (final === "H") {
304
- apply(b => ({ ...b, col: 0 }));
305
- }
306
- else if (final === "F") {
307
- apply(b => ({ ...b, col: b.lines[b.row].length }));
314
+ const csi = CSI_HANDLERS[final];
315
+ if (csi) {
316
+ apply(isWordMod ? csi.wordMod : csi.plain);
308
317
  }
309
318
  else if (final === "u") {
310
- // Kitty keyboard protocol: ESC [ codepoint ; modifier u
311
- const parts = seq.split(";");
312
- const codepoint = parseInt(parts[0], 10);
313
- const mod = parts.length > 1 ? parseInt(parts[1], 10) : 1;
314
- const hasShift = ((mod - 1) & 1) !== 0;
315
- const hasCtrl = ((mod - 1) & 4) !== 0;
316
- const hasCmd = ((mod - 1) & 8) !== 0;
317
- if (codepoint === 13 && hasShift) {
318
- // Shift+Enter → insert newline
319
- apply(b => insertText(b, "\n"));
320
- }
321
- else if (codepoint === 127 && (hasCmd || hasCtrl)) {
322
- // Cmd/Ctrl+Backspace → delete to line start
323
- apply(b => {
324
- const lines = [...b.lines];
325
- lines[b.row] = lines[b.row].slice(b.col);
326
- return { lines, row: b.row, col: 0 };
327
- });
328
- }
319
+ handleKittyKey(seq, apply);
329
320
  }
330
321
  }
331
322
  continue;
@@ -10,7 +10,7 @@ export type ScanDashboardEvent = {
10
10
  fileName: string;
11
11
  status: "scanned" | "failed";
12
12
  transactions: number;
13
- concerns: number;
13
+ unknowns: number;
14
14
  error?: string;
15
15
  };
16
16
  /**
@@ -31,7 +31,7 @@ interface Props {
31
31
  /**
32
32
  * Multi-row live dashboard for the scan phase. Rows appear when a file starts
33
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
34
+ * shown are the in-buffer counts at scan-end; correlation may add unknowns
35
35
  * later, which the terse summary reflects.
36
36
  */
37
37
  export declare function ScanDashboard({ controller, totalFiles, parallel }: Props): import("react/jsx-runtime").JSX.Element;
@@ -23,7 +23,7 @@ export class ScanDashboardController {
23
23
  /**
24
24
  * Multi-row live dashboard for the scan phase. Rows appear when a file starts
25
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
26
+ * shown are the in-buffer counts at scan-end; correlation may add unknowns
27
27
  * later, which the terse summary reflects.
28
28
  */
29
29
  export function ScanDashboard({ controller, totalFiles, parallel }) {
@@ -41,7 +41,7 @@ export function ScanDashboard({ controller, totalFiles, parallel }) {
41
41
  break;
42
42
  case "scan-end":
43
43
  next.set(event.fileName, event.status === "scanned"
44
- ? { kind: "done", transactions: event.transactions, concerns: event.concerns }
44
+ ? { kind: "done", transactions: event.transactions, unknowns: event.unknowns }
45
45
  : { kind: "failed", error: event.error ?? "failed" });
46
46
  break;
47
47
  }
@@ -56,7 +56,7 @@ function FileRow({ name, state }) {
56
56
  return (_jsxs(Text, { children: [" ", _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), " ", name, " ", _jsxs(Text, { dimColor: true, children: ["\u00B7 ", state.step] })] }));
57
57
  }
58
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)"] })] }));
59
+ return (_jsxs(Text, { children: [" ", _jsx(Text, { color: "green", children: "\u2713" }), " ", name, " ", _jsxs(Text, { dimColor: true, children: ["(", state.transactions, " transactions, ", state.unknowns, " unknowns)"] })] }));
60
60
  }
61
61
  return (_jsxs(Text, { children: [" ", _jsx(Text, { color: "red", children: "\u2717" }), " ", name, " ", _jsxs(Text, { dimColor: true, children: ["\u2014 ", state.error] })] }));
62
62
  }
package/dist/cli/setup.js CHANGED
@@ -34,9 +34,12 @@ function printSummary(dataDir) {
34
34
  console.log(chalk.dim(`Data: ${dataDir}`));
35
35
  console.log("");
36
36
  console.log("Next steps:");
37
- console.log(` 1. Run ${chalk.cyan("plasalid data")} to drop your bank/credit card statments PDFs in.`);
38
- console.log(` 2. Run ${chalk.cyan("plasalid scan")} to allow Plasalid to scan them.`);
39
- console.log(` 3. Run ${chalk.cyan("plasalid")} to query your local ledger.`);
37
+ console.log(` 1. Run ${chalk.cyan("plasalid data")} to drop your bank / credit-card statement PDFs in.`);
38
+ console.log(` 2. Run ${chalk.cyan("plasalid scan")} to parse them.`);
39
+ console.log(` 3. Run ${chalk.cyan("plasalid resolve")} to work through anything the scanner flagged.`);
40
+ console.log(` 4. Run ${chalk.cyan("plasalid")} to chat with your money.`);
41
+ console.log("");
42
+ console.log(chalk.dim(` Optional: ${chalk.cyan(`plasalid record "..."`)}${chalk.dim(" to record manual/undocumented transaction, balance, or account at any time.")}`));
40
43
  }
41
44
  /**
42
45
  * Wraps inquirer's list prompt with a blank line above and below, and inserts
package/dist/cli/ux.js CHANGED
@@ -118,7 +118,7 @@ export function makeAgentOnProgress(spinner, subject) {
118
118
  };
119
119
  }
120
120
  /**
121
- * Render the structured facts the review agent attaches to ask_user as a
121
+ * Render the structured facts the resolve agent attaches to ask_user as a
122
122
  * single colored line above the inquirer prompt. Each category has a fixed
123
123
  * chalk color so the user's eye picks out the type without reading prose.
124
124
  * Returns null when there's nothing to render (so the caller can skip the
@@ -0,0 +1,140 @@
1
+ import type Database from "libsql";
2
+ export type AccountType = "asset" | "liability" | "income" | "expense" | "equity";
3
+ export declare const TOP_LEVEL_TYPES: ReadonlyArray<AccountType>;
4
+ export interface AccountRow {
5
+ id: string;
6
+ name: string;
7
+ type: AccountType;
8
+ parent_id: string | null;
9
+ subtype: string | null;
10
+ bank_name: string | null;
11
+ account_number_masked: string | null;
12
+ currency: string;
13
+ due_day: number | null;
14
+ statement_day: number | null;
15
+ points_balance: number | null;
16
+ metadata_json: string | null;
17
+ pii_flag: number;
18
+ has_unknown: number;
19
+ created_at: string;
20
+ }
21
+ export interface AccountBalance extends AccountRow {
22
+ balance: number;
23
+ }
24
+ /**
25
+ * Balance per account using the natural debit/credit convention:
26
+ * asset / expense → debit-normal → balance = debits − credits
27
+ * liability / income / equity → credit-normal → balance = credits − debits
28
+ */
29
+ export declare function getAccountBalances(db: Database.Database, opts?: {
30
+ type?: AccountType;
31
+ }): AccountBalance[];
32
+ export interface NetWorth {
33
+ assets: number;
34
+ liabilities: number;
35
+ net_worth: number;
36
+ }
37
+ export declare function getNetWorth(db: Database.Database): NetWorth;
38
+ export interface PeriodTotals {
39
+ income: number;
40
+ expenses: number;
41
+ }
42
+ export declare function getPeriodTotals(db: Database.Database, from: string, to: string): PeriodTotals;
43
+ export declare function findAccountById(db: Database.Database, id: string): AccountRow | null;
44
+ export declare function renameAccount(db: Database.Database, id: string, name: string): number;
45
+ /**
46
+ * Idempotently insert one of the five top-level type roots (id = type name,
47
+ * parent_id = null). Called by `createAccount` when a child's declared parent
48
+ * is a missing top-level root.
49
+ */
50
+ export declare function ensureTopLevelRoot(db: Database.Database, type: AccountType): void;
51
+ /**
52
+ * Idempotently insert one of the structural accounts the system auto-creates:
53
+ * - `expense:uncategorized` (suspense for unclassifiable expense postings)
54
+ * - `equity:adjustments` (balancing side of `adjust_account_balance`)
55
+ * - `equity:opening-balance` (starting state imports)
56
+ * The top-level root is bootstrapped first when missing.
57
+ */
58
+ export declare function ensureStructuralAccount(db: Database.Database, id: "expense:uncategorized" | "equity:adjustments" | "equity:opening-balance"): void;
59
+ export interface CreateAccountInput {
60
+ id: string;
61
+ name: string;
62
+ type: AccountType;
63
+ parent_id?: string | null;
64
+ subtype?: string | null;
65
+ bank_name?: string | null;
66
+ account_number_masked?: string | null;
67
+ currency?: string;
68
+ due_day?: number | null;
69
+ statement_day?: number | null;
70
+ metadata?: Record<string, unknown> | null;
71
+ }
72
+ /**
73
+ * Insert a new account row. Enforces the three hierarchy invariants:
74
+ * 1. Top-level roots: parent_id null, id == type, one of TOP_LEVEL_TYPES.
75
+ * 2. Children: parent_id non-null, parent must exist (the top-level root is
76
+ * auto-bootstrapped if missing — intermediate categories must be created
77
+ * explicitly), parent.type must equal input.type, input.id must start with
78
+ * parent.id + ':'.
79
+ * 3. UNIQUE on id (surfaces as code: 'ACCOUNT_EXISTS').
80
+ */
81
+ export declare function createAccount(db: Database.Database, input: CreateAccountInput): void;
82
+ export interface UpdateAccountMetadataPatch {
83
+ due_day?: number | null;
84
+ statement_day?: number | null;
85
+ points_balance?: number | null;
86
+ account_number_masked?: string | null;
87
+ bank_name?: string | null;
88
+ metadata?: Record<string, unknown>;
89
+ }
90
+ export interface UpdateAccountMetadataResult {
91
+ before: Record<string, unknown>;
92
+ after: Record<string, unknown>;
93
+ changed: boolean;
94
+ }
95
+ /**
96
+ * Patch metadata fields on an account. Returns before/after snapshots of the
97
+ * touched fields so callers can persist a reversible audit record. `metadata`
98
+ * is shallow-merged into the existing metadata_json blob.
99
+ */
100
+ export declare function updateAccountMetadata(db: Database.Database, id: string, patch: UpdateAccountMetadataPatch): UpdateAccountMetadataResult;
101
+ /**
102
+ * Re-point every posting on `fromId` to `toId`, then delete the source account.
103
+ * Wrapped in a transaction. Refuses if the source still has children. Returns
104
+ * the number of postings moved.
105
+ */
106
+ export declare function mergeAccounts(db: Database.Database, fromId: string, toId: string): number;
107
+ /** Delete an account only if no postings reference it AND it has no children. */
108
+ export declare function deleteAccount(db: Database.Database, id: string): void;
109
+ /**
110
+ * Recursive CTE walk over `accounts.parent_id` returning the root and every
111
+ * descendant. Used by `getRollupBalance` and by hierarchical rendering paths.
112
+ */
113
+ export declare function getAccountSubtree(db: Database.Database, rootId: string): AccountRow[];
114
+ /**
115
+ * Sum the natural balance of every account in a subtree (root inclusive).
116
+ * Uses the same debit-normal / credit-normal convention as `getAccountBalances`.
117
+ */
118
+ export declare function getRollupBalance(db: Database.Database, rootId: string): number;
119
+ export interface SimilarAccountPair {
120
+ a: AccountRow;
121
+ b: AccountRow;
122
+ similarity: number;
123
+ }
124
+ /**
125
+ * Pairwise Levenshtein similarity over `accounts.name`. Returns pairs above the
126
+ * threshold (0–1, where 1 = identical), sorted highest first. Quadratic in the
127
+ * number of accounts — fine for the small N a personal chart of accounts holds.
128
+ */
129
+ export declare function findSimilarAccounts(db: Database.Database, threshold?: number): SimilarAccountPair[];
130
+ export interface FuzzyAccountMatch {
131
+ account: AccountRow;
132
+ similarity: number;
133
+ }
134
+ /**
135
+ * Rank the chart of accounts by name similarity to a free-text query. Returns
136
+ * matches at or above `threshold`, highest first. Bonus weight when the query
137
+ * is a substring of the name so "ttb saving" still finds "TTB Savings ••1234"
138
+ * even though pure Levenshtein on the full strings is mediocre.
139
+ */
140
+ export declare function findAccountsByFuzzyName(db: Database.Database, query: string, threshold?: number): FuzzyAccountMatch[];