plasalid 0.3.5 → 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.
- package/README.md +28 -39
- package/dist/accounts/taxonomy.d.ts +1 -1
- package/dist/accounts/taxonomy.js +2 -2
- package/dist/ai/agent.d.ts +6 -5
- package/dist/ai/agent.js +7 -6
- package/dist/ai/memory.d.ts +12 -5
- package/dist/ai/memory.js +12 -0
- package/dist/ai/personas.d.ts +10 -0
- package/dist/ai/personas.js +123 -0
- package/dist/ai/prompt-sections.d.ts +44 -0
- package/dist/ai/prompt-sections.js +89 -0
- package/dist/ai/system-prompt.d.ts +3 -3
- package/dist/ai/system-prompt.js +44 -165
- package/dist/ai/tools/index.js +12 -7
- package/dist/ai/tools/ingest.d.ts +2 -1
- package/dist/ai/tools/ingest.js +220 -83
- package/dist/ai/tools/read.js +31 -0
- package/dist/ai/tools/review.d.ts +2 -0
- package/dist/ai/tools/review.js +362 -0
- package/dist/ai/tools/scan.js +4 -2
- package/dist/ai/tools/types.d.ts +23 -3
- package/dist/cli/commands/review.d.ts +2 -0
- package/dist/cli/commands/review.js +15 -0
- package/dist/cli/commands/scan.d.ts +4 -2
- package/dist/cli/commands/scan.js +147 -19
- package/dist/cli/index.js +11 -8
- package/dist/cli/ink/scan_dashboard.d.ts +38 -0
- package/dist/cli/ink/scan_dashboard.js +62 -0
- package/dist/cli/ux.d.ts +2 -1
- package/dist/cli/ux.js +36 -2
- package/dist/db/queries/account_balance.d.ts +1 -0
- package/dist/db/queries/concerns.d.ts +47 -0
- package/dist/db/queries/concerns.js +87 -0
- package/dist/db/queries/journal.d.ts +74 -8
- package/dist/db/queries/journal.js +131 -19
- package/dist/db/queries/recurrences.d.ts +33 -0
- package/dist/db/queries/recurrences.js +130 -0
- package/dist/db/schema.js +25 -2
- package/dist/reviewer/pipeline.d.ts +18 -0
- package/dist/reviewer/pipeline.js +46 -0
- package/dist/reviewer/prompts.d.ts +12 -0
- package/dist/reviewer/prompts.js +22 -0
- package/dist/scanner/account_mutex.d.ts +1 -0
- package/dist/scanner/account_mutex.js +16 -0
- package/dist/scanner/buffer.d.ts +48 -0
- package/dist/scanner/buffer.js +63 -0
- package/dist/scanner/concurrency.d.ts +14 -0
- package/dist/scanner/concurrency.js +31 -0
- package/dist/scanner/decrypt_queue.d.ts +57 -0
- package/dist/scanner/decrypt_queue.js +96 -0
- package/dist/scanner/pipeline.d.ts +46 -18
- package/dist/scanner/pipeline.js +250 -97
- package/dist/scanner/prompts.js +1 -1
- package/package.json +1 -1
|
@@ -11,35 +11,163 @@ export async function runScanCommand(opts) {
|
|
|
11
11
|
return;
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
-
const
|
|
14
|
+
const useInk = !!process.stdout.isTTY;
|
|
15
|
+
const events = useInk ? await buildInkEvents(opts.parallel ?? 3) : buildPlainTextEvents();
|
|
16
|
+
const summary = await runScan({
|
|
17
|
+
regex: opts.regex,
|
|
18
|
+
force: opts.force,
|
|
19
|
+
interactive: true,
|
|
20
|
+
concurrency: opts.parallel,
|
|
21
|
+
events,
|
|
22
|
+
});
|
|
15
23
|
renderScanSummary(summary);
|
|
16
24
|
}
|
|
25
|
+
// ── 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
|
-
|
|
20
|
-
|
|
21
|
-
`${
|
|
22
|
-
`${
|
|
23
|
-
|
|
24
|
-
|
|
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.
|
|
28
|
-
case "scanned":
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
155
|
+
}
|
|
156
|
+
case "skipped": {
|
|
157
|
+
console.log(` ${chalk.dim("•")} ${label} ${chalk.dim("(already scanned)")}`);
|
|
36
158
|
break;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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("
|
|
104
|
-
.description("
|
|
105
|
-
.option("-a, --account <id>", "Limit
|
|
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 {
|
|
112
|
-
await
|
|
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: "
|
|
149
|
-
desc: "
|
|
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
|
-
{
|
|
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
|
+
}
|
|
@@ -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[];
|