plasalid 0.3.4 → 0.4.1

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 (54) hide show
  1. package/README.md +29 -40
  2. package/dist/accounts/taxonomy.d.ts +1 -1
  3. package/dist/accounts/taxonomy.js +2 -2
  4. package/dist/ai/agent.d.ts +6 -5
  5. package/dist/ai/agent.js +7 -6
  6. package/dist/ai/memory.d.ts +12 -5
  7. package/dist/ai/memory.js +12 -0
  8. package/dist/ai/personas.d.ts +10 -0
  9. package/dist/ai/personas.js +123 -0
  10. package/dist/ai/prompt-sections.d.ts +44 -0
  11. package/dist/ai/prompt-sections.js +89 -0
  12. package/dist/ai/system-prompt.d.ts +3 -3
  13. package/dist/ai/system-prompt.js +44 -165
  14. package/dist/ai/tools/index.js +12 -7
  15. package/dist/ai/tools/ingest.d.ts +2 -1
  16. package/dist/ai/tools/ingest.js +220 -83
  17. package/dist/ai/tools/read.js +31 -0
  18. package/dist/ai/tools/review.d.ts +2 -0
  19. package/dist/ai/tools/review.js +362 -0
  20. package/dist/ai/tools/scan.js +4 -2
  21. package/dist/ai/tools/types.d.ts +23 -3
  22. package/dist/cli/commands/review.d.ts +2 -0
  23. package/dist/cli/commands/review.js +15 -0
  24. package/dist/cli/commands/scan.d.ts +4 -2
  25. package/dist/cli/commands/scan.js +147 -19
  26. package/dist/cli/index.js +11 -8
  27. package/dist/cli/ink/scan_dashboard.d.ts +38 -0
  28. package/dist/cli/ink/scan_dashboard.js +62 -0
  29. package/dist/cli/ux.d.ts +2 -1
  30. package/dist/cli/ux.js +36 -2
  31. package/dist/db/queries/account_balance.d.ts +1 -0
  32. package/dist/db/queries/concerns.d.ts +47 -0
  33. package/dist/db/queries/concerns.js +87 -0
  34. package/dist/db/queries/journal.d.ts +74 -8
  35. package/dist/db/queries/journal.js +131 -19
  36. package/dist/db/queries/recurrences.d.ts +33 -0
  37. package/dist/db/queries/recurrences.js +130 -0
  38. package/dist/db/schema.js +25 -2
  39. package/dist/reviewer/pipeline.d.ts +18 -0
  40. package/dist/reviewer/pipeline.js +46 -0
  41. package/dist/reviewer/prompts.d.ts +12 -0
  42. package/dist/reviewer/prompts.js +22 -0
  43. package/dist/scanner/account_mutex.d.ts +1 -0
  44. package/dist/scanner/account_mutex.js +16 -0
  45. package/dist/scanner/buffer.d.ts +48 -0
  46. package/dist/scanner/buffer.js +63 -0
  47. package/dist/scanner/concurrency.d.ts +14 -0
  48. package/dist/scanner/concurrency.js +31 -0
  49. package/dist/scanner/decrypt_queue.d.ts +57 -0
  50. package/dist/scanner/decrypt_queue.js +96 -0
  51. package/dist/scanner/pipeline.d.ts +46 -18
  52. package/dist/scanner/pipeline.js +250 -97
  53. package/dist/scanner/prompts.js +1 -1
  54. package/package.json +1 -1
@@ -11,35 +11,163 @@ 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
+ // ── Ink-based events (TTY mode) ────────────────────────────────────────────
26
+ async function buildInkEvents(parallel) {
27
+ // Lazy-load ink + react so this module stays importable in non-TTY contexts
28
+ // (and so test environments without React don't choke on the JSX).
29
+ const { render } = await import("ink");
30
+ const { createElement } = await import("react");
31
+ const { ScanDashboard, ScanDashboardController } = await import("../ink/scan_dashboard.js");
32
+ const controller = new ScanDashboardController();
33
+ let inkInstance = null;
34
+ let mountedFiles = 0;
35
+ return {
36
+ decryptStart: (count) => {
37
+ if (count > 0)
38
+ console.log(chalk.dim(`Decrypting ${count} file(s)...`));
39
+ },
40
+ decryptProgress: (e) => {
41
+ const marker = e.outcome === "decrypted" ? chalk.dim("·")
42
+ : e.outcome === "skipped" ? chalk.dim("•")
43
+ : chalk.red("✗");
44
+ console.log(` ${marker} [${e.index + 1}/${e.total}] ${e.fileName} (${e.outcome})`);
45
+ },
46
+ decryptDone: (e) => {
47
+ console.log(chalk.dim(`Decrypted ${e.decrypted}, skipped ${e.skipped}, failed ${e.failed}.`));
48
+ console.log("");
49
+ mountedFiles = e.decrypted;
50
+ if (e.decrypted > 0) {
51
+ inkInstance = render(createElement(ScanDashboard, { controller, totalFiles: e.decrypted, parallel }));
52
+ }
53
+ },
54
+ scanStart: (e) => controller.publish({ type: "scan-start", fileName: e.fileName }),
55
+ scanProgress: (e) => controller.publish({ type: "scan-progress", fileName: e.fileName, step: e.step }),
56
+ scanEnd: (e) => controller.publish({
57
+ type: "scan-end",
58
+ fileName: e.fileName,
59
+ status: e.status,
60
+ entries: e.entries,
61
+ concerns: e.concerns,
62
+ error: e.error,
63
+ }),
64
+ correlating: (pairs) => {
65
+ if (inkInstance) {
66
+ inkInstance.unmount();
67
+ inkInstance = null;
68
+ }
69
+ if (mountedFiles > 0 && pairs > 0) {
70
+ console.log(chalk.dim(`Correlating across files... ${pairs} pair(s) flagged.`));
71
+ }
72
+ },
73
+ committing: () => {
74
+ // In case correlating fired with 0 pairs, ink may still be mounted; unmount now.
75
+ if (inkInstance) {
76
+ inkInstance.unmount();
77
+ inkInstance = null;
78
+ }
79
+ if (mountedFiles > 0)
80
+ console.log(chalk.dim("Committing..."));
81
+ },
82
+ };
83
+ }
84
+ // ── Plain-text progress (TTY or piped, no ink yet) ─────────────────────────
85
+ function buildPlainTextEvents() {
86
+ let decryptTotal = 0;
87
+ // De-dupe scan-progress chatter: only print when the step text changes per file.
88
+ const lastStepByFile = new Map();
89
+ return {
90
+ decryptStart: (count) => {
91
+ decryptTotal = count;
92
+ if (count > 0)
93
+ console.log(chalk.dim(`Decrypting ${count} file(s)...`));
94
+ },
95
+ decryptProgress: (e) => {
96
+ const marker = e.outcome === "decrypted" ? chalk.dim("·")
97
+ : e.outcome === "skipped" ? chalk.dim("•")
98
+ : chalk.red("✗");
99
+ console.log(` ${marker} [${e.index + 1}/${e.total}] ${e.fileName} (${e.outcome})`);
100
+ },
101
+ decryptDone: (e) => {
102
+ if (decryptTotal === 0)
103
+ return;
104
+ console.log(chalk.dim(`Decrypted ${e.decrypted}, skipped ${e.skipped}, failed ${e.failed}.`));
105
+ console.log("");
106
+ },
107
+ scanStart: (e) => {
108
+ console.log(`${chalk.cyan("→")} ${e.fileName} ${chalk.dim("starting...")}`);
109
+ },
110
+ scanProgress: (e) => {
111
+ if (lastStepByFile.get(e.fileName) === e.step)
112
+ return;
113
+ lastStepByFile.set(e.fileName, e.step);
114
+ console.log(chalk.dim(` ${e.fileName} · ${e.step}`));
115
+ },
116
+ scanEnd: (e) => {
117
+ lastStepByFile.delete(e.fileName);
118
+ if (e.status === "scanned") {
119
+ console.log(`${chalk.green("✓")} ${e.fileName} ${chalk.dim(`(${e.entries} entries, ${e.concerns} concerns)`)}`);
120
+ }
121
+ else {
122
+ console.log(`${chalk.red("✗")} ${e.fileName} ${chalk.dim(`— ${e.error ?? "failed"}`)}`);
123
+ }
124
+ },
125
+ correlating: (pairs) => {
126
+ if (pairs > 0)
127
+ console.log(chalk.dim(`Correlating across files... ${pairs} pair(s) flagged.`));
128
+ },
129
+ committing: () => {
130
+ console.log(chalk.dim("Committing..."));
131
+ },
132
+ };
133
+ }
134
+ // ── Terse summary ──────────────────────────────────────────────────────────
17
135
  function renderScanSummary(summary) {
18
136
  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`)}`);
137
+ const headline = `Scanned ${summary.total} file(s)` +
138
+ `${summary.scanned + summary.replaced} ok, ` +
139
+ `${summary.failed} failed, ` +
140
+ `${summary.concerns} concern${summary.concerns === 1 ? "" : "s"} flagged`;
141
+ console.log(chalk.bold(headline));
142
+ console.log("");
25
143
  for (const d of summary.details) {
26
144
  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}`) : ""}`);
145
+ switch (d.status) {
146
+ case "scanned": {
147
+ const tag = chalk.dim(`${d.entries} entries${d.concerns > 0 ? ` · ${d.concerns} concerns` : ""}`);
148
+ console.log(` ${chalk.green("✓")} ${label} ${tag}`);
30
149
  break;
31
- case "replaced":
32
- console.log(` ${chalk.cyan("")} ${label} (replaces previous records)`);
150
+ }
151
+ case "replaced": {
152
+ const tag = chalk.dim(`${d.entries} entries${d.concerns > 0 ? ` · ${d.concerns} concerns` : ""} (replaces prior)`);
153
+ console.log(` ${chalk.cyan("↻")} ${label} ${tag}`);
33
154
  break;
34
- case "skipped":
35
- console.log(` ${chalk.dim("")} ${label} (already scanned)`);
155
+ }
156
+ case "skipped": {
157
+ console.log(` ${chalk.dim("•")} ${label} ${chalk.dim("(already scanned)")}`);
36
158
  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}`) : ""}`);
159
+ }
160
+ case "failed": {
161
+ console.log(` ${chalk.red("✗")} ${label} ${chalk.dim(`— ${d.error ?? "failed"}`)}`);
42
162
  break;
163
+ }
43
164
  }
44
165
  }
166
+ const newlyProcessed = summary.scanned + summary.replaced;
167
+ if (newlyProcessed > 0) {
168
+ console.log("");
169
+ console.log(`${chalk.dim("Next:")} ${chalk.cyan("plasalid review")}${chalk.dim(summary.concerns > 0
170
+ ? " — to clear the concerns and learn your recurring rhythms."
171
+ : " — to connect related transactions and learn your recurring rhythms.")}`);
172
+ }
45
173
  }
package/dist/cli/index.js CHANGED
@@ -83,6 +83,7 @@ program
83
83
  .command("scan [regex...]")
84
84
  .description("Scan every new PDF under ~/.plasalid/data (optionally filtered by regex)")
85
85
  .option("-f, --force", "Re-scan matching files (cascade-deletes prior records)")
86
+ .option("-p, --parallel <n>", "Number of files to scan concurrently (default 3, max 8). Override env PLASALID_SCAN_CONCURRENCY.", (v) => parseInt(v, 10))
86
87
  .action(async (regexes, opts) => {
87
88
  ensureConfigured();
88
89
  if (regexes.length > 1) {
@@ -96,20 +97,22 @@ program
96
97
  console.error(` ${chalk.cyan("plasalid scan 'KBank|SCB'")}`);
97
98
  process.exit(1);
98
99
  }
100
+ const envParallel = parseInt(process.env.PLASALID_SCAN_CONCURRENCY ?? "", 10);
101
+ const parallel = Number.isFinite(opts.parallel) ? opts.parallel : (Number.isFinite(envParallel) ? envParallel : undefined);
99
102
  const { runScanCommand } = await import("./commands/scan.js");
100
- await runScanCommand({ regex: regexes[0], force: !!opts.force });
103
+ await runScanCommand({ regex: regexes[0], force: !!opts.force, parallel });
101
104
  });
102
105
  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")
106
+ .command("review")
107
+ .description("See the whole picture connect related transactions across statements, learn the rhythm of your recurring money, and clear up anything that's still in question.")
108
+ .option("-a, --account <id>", "Limit review to a single account")
106
109
  .option("--from <date>", "Only consider entries on or after this date (YYYY-MM-DD)")
107
110
  .option("--to <date>", "Only consider entries on or before this date (YYYY-MM-DD)")
108
111
  .option("-d, --dry-run", "Surface findings without applying any change")
109
112
  .action(async (opts) => {
110
113
  ensureConfigured();
111
- const { runReconcileCommand } = await import("./commands/reconcile.js");
112
- await runReconcileCommand({
114
+ const { runReviewCommand } = await import("./commands/review.js");
115
+ await runReviewCommand({
113
116
  accountId: opts.account,
114
117
  from: opts.from,
115
118
  to: opts.to,
@@ -145,8 +148,8 @@ program.configureHelp({
145
148
  desc: "Scan new PDFs (optionally by regex; --force to re-scan)",
146
149
  },
147
150
  {
148
- name: "reconcile",
149
- desc: "Review and fix existing journal entries / accounts",
151
+ name: "review",
152
+ desc: "Connect the dots and learn your recurring rhythms",
150
153
  },
151
154
  {
152
155
  name: "revert",
@@ -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
+ entries: 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", entries: event.entries, 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.entries, " entries, ", state.concerns, " concerns)"] })] }));
60
+ }
61
+ return (_jsxs(Text, { children: [" ", _jsx(Text, { color: "red", children: "\u2717" }), " ", name, " ", _jsxs(Text, { dimColor: true, children: ["\u2014 ", state.error] })] }));
62
+ }
package/dist/cli/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`.
package/dist/cli/ux.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import inquirer from "inquirer";
2
2
  import ora from "ora";
3
+ import chalk from "chalk";
3
4
  import { TOOL_LABELS } from "../ai/tools/index.js";
4
5
  import { pickThinking } from "../ai/thinking.js";
5
6
  import { formatDuration } from "./format.js";
@@ -45,9 +46,12 @@ export function statusSpinner(text) {
45
46
  */
46
47
  export function makePromptUser(spinner) {
47
48
  const OTHER = "__plasalid_other__";
48
- return async (prompt, options) => {
49
+ return async (prompt, options, facts) => {
49
50
  spinner.pause();
50
51
  console.log("");
52
+ const factsLine = facts ? formatFacts(facts) : null;
53
+ if (factsLine)
54
+ console.log(factsLine);
51
55
  try {
52
56
  if (options && options.length > 0) {
53
57
  const choices = [
@@ -60,7 +64,18 @@ export function makePromptUser(spinner) {
60
64
  { name: "Type a different answer…", value: OTHER },
61
65
  ];
62
66
  const { choice } = await inquirer.prompt([
63
- { type: "list", name: "choice", message: prompt, choices },
67
+ {
68
+ type: "list",
69
+ name: "choice",
70
+ message: prompt,
71
+ choices,
72
+ // Stop the cursor at the top/bottom instead of wrapping forever —
73
+ // the wrap-around default makes the list feel infinite.
74
+ loop: false,
75
+ // Show every choice without paginating. Floor of 10 keeps the
76
+ // prompt height predictable for small option sets.
77
+ pageSize: Math.max(choices.length, 10),
78
+ },
64
79
  ]);
65
80
  if (choice === OTHER) {
66
81
  const { freeform } = await inquirer.prompt([
@@ -102,3 +117,22 @@ export function makeAgentOnProgress(spinner, subject) {
102
117
  }
103
118
  };
104
119
  }
120
+ /**
121
+ * Render the structured facts the review agent attaches to ask_user as a
122
+ * single colored line above the inquirer prompt. Each category has a fixed
123
+ * chalk color so the user's eye picks out the type without reading prose.
124
+ * Returns null when there's nothing to render (so the caller can skip the
125
+ * blank line entirely).
126
+ */
127
+ function formatFacts(f) {
128
+ const parts = [];
129
+ if (f.amount)
130
+ parts.push(chalk.yellow(f.amount));
131
+ if (f.date)
132
+ parts.push(chalk.cyan(f.date));
133
+ if (f.merchant)
134
+ parts.push(chalk.green(f.merchant));
135
+ for (const a of f.accounts ?? [])
136
+ parts.push(chalk.magenta(a));
137
+ return parts.length ? parts.join(chalk.dim(" · ")) : null;
138
+ }
@@ -13,6 +13,7 @@ export interface AccountRow {
13
13
  points_balance: number | null;
14
14
  metadata_json: string | null;
15
15
  pii_flag: number;
16
+ has_concern: number;
16
17
  created_at: string;
17
18
  }
18
19
  export interface AccountBalance extends AccountRow {
@@ -0,0 +1,47 @@
1
+ import type Database from "libsql";
2
+ export interface ConcernTarget {
3
+ entry_id: string | null;
4
+ account_id: string | null;
5
+ }
6
+ export interface RecordConcernInput extends ConcernTarget {
7
+ file_id: string | null;
8
+ prompt: string;
9
+ options?: string[];
10
+ }
11
+ export interface OpenConcernRow {
12
+ id: string;
13
+ file_id: string | null;
14
+ entry_id: string | null;
15
+ account_id: string | null;
16
+ prompt: string;
17
+ options_json: string | null;
18
+ created_at: string;
19
+ }
20
+ /**
21
+ * Insert a new concerns row and flip the `has_concern` boolean on whichever
22
+ * target (entry / account) was named. Returns the new `cn:<uuid>` id.
23
+ */
24
+ export declare function recordConcern(db: Database.Database, input: RecordConcernInput): string;
25
+ /**
26
+ * Mark an existing concern as resolved with the user's answer and, if no other
27
+ * open concerns reference the same target, clear the target's `has_concern`
28
+ * flag. Returns the concern's target so callers can log or react.
29
+ */
30
+ export declare function resolveConcern(db: Database.Database, id: string, answer: string): ConcernTarget | null;
31
+ /**
32
+ * Look up the entry/account a concern is attached to. Returns null when the
33
+ * concern id doesn't exist.
34
+ */
35
+ export declare function getConcernTarget(db: Database.Database, id: string): ConcernTarget | null;
36
+ /**
37
+ * Clear `has_concern` on the named entry / account if no other open concerns
38
+ * still reference it. Safe to call after any concern resolution; idempotent.
39
+ */
40
+ export declare function maybeClearHasConcernFlags(db: Database.Database, target: ConcernTarget): void;
41
+ export interface CountOpenConcernsScope {
42
+ file_id?: string;
43
+ entry_id?: string;
44
+ account_id?: string;
45
+ }
46
+ export declare function countOpenConcerns(db: Database.Database, scope?: CountOpenConcernsScope): number;
47
+ export declare function listOpenConcerns(db: Database.Database, limit?: number): OpenConcernRow[];
@@ -0,0 +1,87 @@
1
+ import { randomUUID } from "crypto";
2
+ /**
3
+ * Insert a new concerns row and flip the `has_concern` boolean on whichever
4
+ * target (entry / account) was named. Returns the new `cn:<uuid>` id.
5
+ */
6
+ export function recordConcern(db, input) {
7
+ const id = `cn:${randomUUID()}`;
8
+ db.prepare(`INSERT INTO concerns (id, file_id, entry_id, account_id, prompt, options_json) VALUES (?, ?, ?, ?, ?, ?)`).run(id, input.file_id, input.entry_id, input.account_id, input.prompt, input.options ? JSON.stringify(input.options) : null);
9
+ if (input.entry_id) {
10
+ db.prepare(`UPDATE journal_entries SET has_concern = 1 WHERE id = ?`).run(input.entry_id);
11
+ }
12
+ if (input.account_id) {
13
+ db.prepare(`UPDATE accounts SET has_concern = 1 WHERE id = ?`).run(input.account_id);
14
+ }
15
+ return id;
16
+ }
17
+ /**
18
+ * Mark an existing concern as resolved with the user's answer and, if no other
19
+ * open concerns reference the same target, clear the target's `has_concern`
20
+ * flag. Returns the concern's target so callers can log or react.
21
+ */
22
+ export function resolveConcern(db, id, answer) {
23
+ const target = getConcernTarget(db, id);
24
+ if (!target)
25
+ return null;
26
+ db.prepare(`UPDATE concerns SET answer = ?, resolved_at = datetime('now') WHERE id = ?`).run(answer, id);
27
+ maybeClearHasConcernFlags(db, target);
28
+ return target;
29
+ }
30
+ /**
31
+ * Look up the entry/account a concern is attached to. Returns null when the
32
+ * concern id doesn't exist.
33
+ */
34
+ export function getConcernTarget(db, id) {
35
+ const row = db
36
+ .prepare(`SELECT entry_id, account_id FROM concerns WHERE id = ?`)
37
+ .get(id);
38
+ return row ?? null;
39
+ }
40
+ /**
41
+ * Clear `has_concern` on the named entry / account if no other open concerns
42
+ * still reference it. Safe to call after any concern resolution; idempotent.
43
+ */
44
+ export function maybeClearHasConcernFlags(db, target) {
45
+ if (target.entry_id) {
46
+ const open = db
47
+ .prepare(`SELECT 1 FROM concerns WHERE entry_id = ? AND resolved_at IS NULL LIMIT 1`)
48
+ .get(target.entry_id);
49
+ if (!open)
50
+ db.prepare(`UPDATE journal_entries SET has_concern = 0 WHERE id = ?`).run(target.entry_id);
51
+ }
52
+ if (target.account_id) {
53
+ const open = db
54
+ .prepare(`SELECT 1 FROM concerns WHERE account_id = ? AND resolved_at IS NULL LIMIT 1`)
55
+ .get(target.account_id);
56
+ if (!open)
57
+ db.prepare(`UPDATE accounts SET has_concern = 0 WHERE id = ?`).run(target.account_id);
58
+ }
59
+ }
60
+ export function countOpenConcerns(db, scope = {}) {
61
+ const conditions = ["resolved_at IS NULL"];
62
+ const params = [];
63
+ if (scope.file_id) {
64
+ conditions.push("file_id = ?");
65
+ params.push(scope.file_id);
66
+ }
67
+ if (scope.entry_id) {
68
+ conditions.push("entry_id = ?");
69
+ params.push(scope.entry_id);
70
+ }
71
+ if (scope.account_id) {
72
+ conditions.push("account_id = ?");
73
+ params.push(scope.account_id);
74
+ }
75
+ const row = db
76
+ .prepare(`SELECT COUNT(*) AS n FROM concerns WHERE ${conditions.join(" AND ")}`)
77
+ .get(...params);
78
+ return row.n;
79
+ }
80
+ export function listOpenConcerns(db, limit = 50) {
81
+ const capped = Math.min(Math.max(limit, 1), 200);
82
+ return db.prepare(`SELECT id, file_id, entry_id, account_id, prompt, options_json, created_at
83
+ FROM concerns
84
+ WHERE resolved_at IS NULL
85
+ ORDER BY created_at ASC
86
+ LIMIT ?`).all(capped);
87
+ }
@@ -8,20 +8,14 @@ export interface JournalLineInput {
8
8
  pii_flag?: boolean;
9
9
  }
10
10
  export interface JournalEntryInput {
11
+ /** Optional pre-assigned id. Used by the buffered-write path so concerns recorded mid-scan can reference the entry before commit. */
12
+ id?: string;
11
13
  date: string;
12
14
  description: string;
13
15
  source_file_id?: string | null;
14
16
  source_page?: number | null;
15
17
  lines: JournalLineInput[];
16
18
  }
17
- export interface JournalEntryRow {
18
- id: string;
19
- date: string;
20
- description: string;
21
- source_file_id: string | null;
22
- source_page: number | null;
23
- created_at: string;
24
- }
25
19
  export interface JournalLineRow {
26
20
  id: string;
27
21
  entry_id: string;
@@ -41,6 +35,22 @@ export interface JournalLineRow {
41
35
  * a header, header never lands without lines.
42
36
  */
43
37
  export declare function recordJournalEntry(db: Database.Database, entry: JournalEntryInput): string;
38
+ /**
39
+ * Validate balance + invariants and assign an id. Pure (no DB writes). Used by
40
+ * both `recordJournalEntry` and the buffered-scan commit path; the latter
41
+ * already runs inside its own transaction and must not open another.
42
+ */
43
+ export declare function validateJournalEntry(entry: JournalEntryInput): JournalEntryInput & {
44
+ id: string;
45
+ };
46
+ /**
47
+ * Insert-only counterpart to `recordJournalEntry`. The caller is responsible
48
+ * for opening a transaction (or for accepting partial writes). Expects an
49
+ * already-validated entry from `validateJournalEntry`.
50
+ */
51
+ export declare function insertJournalEntryRows(db: Database.Database, entry: JournalEntryInput & {
52
+ id: string;
53
+ }): void;
44
54
  export interface ListJournalLinesOptions {
45
55
  account_id?: string;
46
56
  from?: string;
@@ -92,4 +102,60 @@ export interface FindDuplicateEntriesOptions {
92
102
  * account_names (for human-readable presentation to the user).
93
103
  */
94
104
  export declare function findDuplicateEntries(db: Database.Database, opts?: FindDuplicateEntriesOptions): DuplicateGroupEntry[][];
105
+ export declare function dayDiff(a: string, b: string): number;
106
+ export interface CorrelatedEntryPair {
107
+ amount: number;
108
+ currency: string;
109
+ day_gap: number;
110
+ a: {
111
+ id: string;
112
+ date: string;
113
+ description: string;
114
+ account_ids: string[];
115
+ account_names: string[];
116
+ };
117
+ b: {
118
+ id: string;
119
+ date: string;
120
+ description: string;
121
+ account_ids: string[];
122
+ account_names: string[];
123
+ };
124
+ }
125
+ export interface FindCorrelatedEntriesOptions {
126
+ from?: string;
127
+ to?: string;
128
+ /** Max day difference between paired entries. Default 3. */
129
+ toleranceDays?: number;
130
+ /** Skip entries below this total debit. Default 0. */
131
+ minAmount?: number;
132
+ }
133
+ /**
134
+ * Heuristic: surface pairs of entries that look like the same money movement
135
+ * recorded against different accounts (e.g. a bank-to-card transfer that lands
136
+ * once on the bank statement and again on the card statement). Filters out
137
+ * pairs whose account-id sets overlap (those are duplicates, not correlations).
138
+ */
139
+ export declare function findCorrelatedEntries(db: Database.Database, opts?: FindCorrelatedEntriesOptions): CorrelatedEntryPair[];
140
+ export interface CorrelationCandidate {
141
+ id: string;
142
+ date: string;
143
+ description: string;
144
+ amount: number;
145
+ currency: string;
146
+ account_ids: string[];
147
+ account_names: string[];
148
+ }
149
+ /**
150
+ * Pure pair-finder: given an array of candidates already filtered by amount
151
+ * and equipped with account_ids/names, return the cross-pairs that look like
152
+ * the same money movement on different accounts (date within toleranceDays,
153
+ * same amount + currency, non-overlapping account sets).
154
+ *
155
+ * Used by the DB-backed `findCorrelatedEntries` and by the scan-time
156
+ * coordinator that runs over buffered, not-yet-committed entries.
157
+ */
158
+ export declare function correlatePairs(entries: CorrelationCandidate[], opts?: {
159
+ toleranceDays?: number;
160
+ }): CorrelatedEntryPair[];
95
161
  export declare function listJournalLines(db: Database.Database, opts?: ListJournalLinesOptions): JournalLineRow[];