plasalid 0.8.2 → 0.9.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 (64) hide show
  1. package/README.md +4 -0
  2. package/dist/ai/personas.js +29 -6
  3. package/dist/ai/prompt-sections.d.ts +10 -0
  4. package/dist/ai/prompt-sections.js +29 -0
  5. package/dist/ai/system-prompt.js +10 -6
  6. package/dist/ai/tools/clarify.js +35 -0
  7. package/dist/ai/tools/common.js +3 -2
  8. package/dist/ai/tools/index.js +6 -3
  9. package/dist/ai/tools/ingest.js +47 -35
  10. package/dist/ai/tools/mutate.d.ts +2 -0
  11. package/dist/ai/tools/mutate.js +81 -0
  12. package/dist/cli/commands/accounts.d.ts +1 -4
  13. package/dist/cli/commands/accounts.js +12 -101
  14. package/dist/cli/commands/files.d.ts +7 -0
  15. package/dist/cli/commands/files.js +24 -0
  16. package/dist/cli/commands/rules.d.ts +4 -12
  17. package/dist/cli/commands/rules.js +33 -67
  18. package/dist/cli/commands/scan.js +14 -12
  19. package/dist/cli/commands/status.js +5 -3
  20. package/dist/cli/commands/transactions.d.ts +0 -2
  21. package/dist/cli/commands/transactions.js +10 -63
  22. package/dist/cli/format.js +22 -32
  23. package/dist/cli/helper.d.ts +9 -1
  24. package/dist/cli/helper.js +17 -2
  25. package/dist/cli/index.js +37 -32
  26. package/dist/cli/ink/FilesBrowser.d.ts +7 -0
  27. package/dist/cli/ink/FilesBrowser.js +103 -0
  28. package/dist/cli/ink/ListBrowser.d.ts +16 -1
  29. package/dist/cli/ink/ListBrowser.js +36 -49
  30. package/dist/cli/ink/PromptFrame.js +1 -1
  31. package/dist/cli/ink/RulesBrowser.d.ts +7 -0
  32. package/dist/cli/ink/RulesBrowser.js +67 -0
  33. package/dist/cli/ink/ScanDashboard.js +90 -68
  34. package/dist/cli/ink/hooks/useFooterText.js +14 -22
  35. package/dist/cli/ink/keys.d.ts +2 -0
  36. package/dist/cli/ink/keys.js +19 -0
  37. package/dist/db/queries/files.d.ts +29 -0
  38. package/dist/db/queries/files.js +34 -0
  39. package/dist/db/queries/questions.d.ts +17 -0
  40. package/dist/db/queries/questions.js +47 -9
  41. package/dist/db/queries/rules.d.ts +31 -0
  42. package/dist/db/queries/rules.js +55 -0
  43. package/dist/db/queries/transactions.d.ts +34 -0
  44. package/dist/db/queries/transactions.js +86 -0
  45. package/dist/db/schema.js +17 -0
  46. package/dist/scanner/clarifier-memory.d.ts +15 -3
  47. package/dist/scanner/clarifier-memory.js +38 -17
  48. package/dist/scanner/clarifier.d.ts +2 -1
  49. package/dist/scanner/clarifier.js +40 -26
  50. package/dist/scanner/commit-pipeline.d.ts +56 -0
  51. package/dist/scanner/commit-pipeline.js +204 -0
  52. package/dist/scanner/committer.d.ts +56 -0
  53. package/dist/scanner/committer.js +204 -0
  54. package/dist/scanner/parse.js +27 -7
  55. package/dist/scanner/recurrence-pipeline.d.ts +28 -0
  56. package/dist/scanner/recurrence-pipeline.js +126 -0
  57. package/dist/scanner/recurrence.d.ts +28 -0
  58. package/dist/scanner/recurrence.js +155 -0
  59. package/dist/scanner/rule-keys.d.ts +13 -0
  60. package/dist/scanner/rule-keys.js +28 -0
  61. package/dist/scanner/rules.d.ts +13 -0
  62. package/dist/scanner/rules.js +28 -0
  63. package/dist/scanner/worker.js +4 -2
  64. package/package.json +1 -1
@@ -1,7 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useState } from "react";
3
- import { Box, Text, useStdout } from "ink";
2
+ import { memo, useEffect, useMemo, useState } from "react";
3
+ import { Box, Text } from "ink";
4
4
  import Spinner from "ink-spinner";
5
+ import chalk from "chalk";
6
+ import { padRight, truncateMiddle } from "../helper.js";
7
+ import { ListBrowser } from "./ListBrowser.js";
8
+ import { keyOf } from "./keys.js";
5
9
  export function createScanDashboardController() {
6
10
  const subscribers = new Set();
7
11
  return {
@@ -25,6 +29,7 @@ function classify(input, rules) {
25
29
  throw new Error("classify: no rule matched (missing catch-all?)");
26
30
  }
27
31
  const COL = {
32
+ marker: 2, // "▸ " or " "
28
33
  status: 14,
29
34
  files: 34,
30
35
  transactions: 13,
@@ -33,11 +38,36 @@ const COL = {
33
38
  export function ScanDashboard(props) {
34
39
  const rows = useFileGroups(props.controller, props.files);
35
40
  const phase = usePhase(props.controller);
36
- const ruleWidth = useRuleWidth();
37
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { phase: phase }), _jsx(AttachmentLine, { info: props.attachment }), _jsx(Box, { marginTop: 1, children: _jsx(ColumnHeader, {}) }), _jsx(Divider, { width: ruleWidth }), Array.from(rows.entries()).map(([fileId, group]) => (_jsx(FileGroupView, { group: group }, fileId))), _jsx(Divider, { width: ruleWidth }), phase !== "done" && _jsx(Footnote, {})] }));
41
+ const spinnerFrame = useSpinnerFrame();
42
+ const items = useMemo(() => Array.from(rows.entries(), ([fileId, group]) => ({ fileId, group })), [rows]);
43
+ const adapter = useMemo(() => ({
44
+ headerNode: (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { phase: phase }), _jsx(AttachmentLine, { info: props.attachment }), _jsx(ColumnHeader, {})] })),
45
+ items,
46
+ getId: (i) => i.fileId,
47
+ renderRow: (i, ctx) => renderFileRow(i.group, ctx.isCursor, ctx.isExpanded, spinnerFrame, phase),
48
+ renderExpanded: (i) => (_jsx(ChunkList, { chunks: i.group.chunks, frame: spinnerFrame })),
49
+ getExpandedHeight: (i) => i.group.chunks.size,
50
+ matches: (i, needle) => i.group.fileName.toLowerCase().includes(needle),
51
+ summary: phase !== "done" ? _jsx(Footnote, {}) : null,
52
+ onKey: (input, key) => {
53
+ const k = keyOf(input, key);
54
+ return k === "q" || k === "escape";
55
+ },
56
+ emptyMessage: "No files in the scan queue.",
57
+ }), [phase, items, props.attachment, spinnerFrame]);
58
+ return _jsx(ListBrowser, { adapter: adapter });
59
+ }
60
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
61
+ function useSpinnerFrame() {
62
+ const [i, setI] = useState(0);
63
+ useEffect(() => {
64
+ const id = setInterval(() => setI((prev) => (prev + 1) % SPINNER_FRAMES.length), 80);
65
+ return () => clearInterval(id);
66
+ }, []);
67
+ return SPINNER_FRAMES[i];
38
68
  }
39
69
  function Footnote() {
40
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "output accuracy depends on the model's VL capability." }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "we also provide " }), _jsx(Text, { color: "cyan", children: "clarify" }), _jsx(Text, { dimColor: true, children: ", " }), _jsx(Text, { color: "cyan", children: "record" }), _jsx(Text, { dimColor: true, children: ", and " }), _jsx(Text, { color: "cyan", children: "chat" }), _jsx(Text, { dimColor: true, children: " to rectify the data later." })] })] }));
70
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Output accuracy depends on the model's vision capability." }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "You can run " }), _jsx(Text, { color: "cyan", children: "clarify" }), _jsx(Text, { dimColor: true, children: ", " }), _jsx(Text, { color: "cyan", children: "record" }), _jsx(Text, { dimColor: true, children: ", and " }), _jsx(Text, { color: "cyan", children: "chat" }), _jsx(Text, { dimColor: true, children: " to correct the data later." })] })] }));
41
71
  }
42
72
  function AttachmentLine({ info }) {
43
73
  const detail = info.format === "pdf" ? "pdf (native)" : "png (rasterized)";
@@ -51,20 +81,6 @@ function usePhase(controller) {
51
81
  }), [controller]);
52
82
  return phase;
53
83
  }
54
- function useRuleWidth() {
55
- const { stdout } = useStdout();
56
- const [cols, setCols] = useState(() => stdout?.columns ?? 100);
57
- useEffect(() => {
58
- if (!stdout)
59
- return;
60
- const onResize = () => setCols(stdout.columns ?? 100);
61
- stdout.on("resize", onResize);
62
- return () => {
63
- stdout.off("resize", onResize);
64
- };
65
- }, [stdout]);
66
- return Math.min(cols, 120);
67
- }
68
84
  const PHASE_RENDER = {
69
85
  pending: (label) => _jsx(Text, { dimColor: true, children: label }),
70
86
  running: (label) => (_jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " ", label] })),
@@ -81,28 +97,40 @@ function phaseStateOf(label, current) {
81
97
  return "pending";
82
98
  }
83
99
  function Header({ phase }) {
84
- // Cancellation collapses the parse/clarify segments — neither is still
85
- // running once the user hits Ctrl+C, and showing them as "pending" would
86
- // be misleading. The single "cancelling…" label communicates the wind-down.
87
100
  if (phase === "cancelling") {
88
101
  return (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Scanner" }), _jsx(Text, { dimColor: true, children: " · " }), _jsx(Text, { color: "green", children: "\u2713 decrypt" }), _jsx(Text, { dimColor: true, children: " -> " }), _jsx(Text, { color: "green", children: "\u2713 chunk" }), _jsx(Text, { dimColor: true, children: " -> " }), _jsxs(Text, { color: "red", children: [_jsx(Spinner, { type: "dots" }), " cancelling\u2026"] })] }));
89
102
  }
90
103
  return (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Scanner" }), _jsx(Text, { dimColor: true, children: " · " }), _jsx(Text, { color: "green", children: "\u2713 decrypt" }), _jsx(Text, { dimColor: true, children: " -> " }), _jsx(Text, { color: "green", children: "\u2713 chunk" }), _jsx(Text, { dimColor: true, children: " -> " }), PHASE_RENDER[phaseStateOf("parse", phase)]("parse"), _jsx(Text, { dimColor: true, children: " -> " }), PHASE_RENDER[phaseStateOf("clarify", phase)]("clarify")] }));
91
104
  }
92
105
  function ColumnHeader() {
93
- return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: COL.status, children: _jsx(Text, { dimColor: true, children: "status" }) }), _jsx(Box, { width: COL.files, children: _jsx(Text, { dimColor: true, children: "files" }) }), _jsx(Box, { width: COL.transactions, children: _jsx(Text, { dimColor: true, children: "transactions" }) }), _jsx(Box, { width: COL.questions, children: _jsx(Text, { dimColor: true, children: "questions" }) })] }));
94
- }
95
- function Divider({ width }) {
96
- return _jsx(Text, { dimColor: true, children: "─".repeat(width) });
97
- }
98
- const spin = (label) => () => (_jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " ", label] }));
99
- const STATUS_RENDER = {
100
- queued: () => _jsx(Text, { color: "gray", children: "queued" }),
101
- running: spin("running"),
102
- scanning: spin("scanning"),
103
- done: () => _jsx(Text, { color: "green", children: "\u2713 done" }),
104
- failed: () => _jsx(Text, { color: "red", children: "failed" }),
105
- partial: () => _jsx(Text, { color: "yellow", children: "partial" }),
106
+ return (_jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(Box, { width: COL.marker }), _jsx(Box, { width: COL.status, children: _jsx(Text, { dimColor: true, children: "status" }) }), _jsx(Box, { width: COL.files, children: _jsx(Text, { dimColor: true, children: "files" }) }), _jsx(Box, { width: COL.transactions, children: _jsx(Text, { dimColor: true, children: "transactions" }) }), _jsx(Box, { width: COL.questions, children: _jsx(Text, { dimColor: true, children: "questions" }) })] }));
107
+ }
108
+ function statusText(status, frame) {
109
+ switch (status) {
110
+ case "queued":
111
+ return "queued";
112
+ case "running":
113
+ return `${frame} running`;
114
+ case "scanning":
115
+ return `${frame} scanning`;
116
+ case "clarify":
117
+ return `${frame} clarify`;
118
+ case "done":
119
+ return "✓ done";
120
+ case "failed":
121
+ return "failed";
122
+ case "partial":
123
+ return "partial";
124
+ }
125
+ }
126
+ const STATUS_COLOR = {
127
+ queued: chalk.gray,
128
+ running: chalk.yellow,
129
+ scanning: chalk.yellow,
130
+ clarify: chalk.yellow,
131
+ done: chalk.green,
132
+ failed: chalk.red,
133
+ partial: chalk.yellow,
106
134
  };
107
135
  const FILE_STATUS_RULES = [
108
136
  { when: ({ finished, total }) => finished < total, state: "scanning" },
@@ -123,40 +151,34 @@ function aggregate(chunks, total) {
123
151
  const status = classify({ finished: done + failed, failed, total }, FILE_STATUS_RULES);
124
152
  return { totalTx, totalQuestions, status };
125
153
  }
126
- function FileGroupView({ group }) {
127
- const chunks = Array.from(group.chunks.values()).sort((a, b) => a.pageNumber - b.pageNumber);
154
+ function renderFileRow(group, isCursor, isExpanded, frame, phase) {
155
+ const chunks = Array.from(group.chunks.values());
128
156
  const agg = aggregate(chunks, group.totalChunks);
129
- const fileName = `> ${truncateMiddle(group.fileName, COL.files - 2)}`;
130
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Row, { status: _jsx(StatusText, { status: agg.status }), files: _jsx(Text, { dimColor: true, children: fileName }), transactions: agg.totalTx, questions: agg.totalQuestions }), chunks.map((c) => (_jsx(ChunkRow, { chunk: c }, c.pageNumber)))] }));
131
- }
132
- function ChunkRow({ chunk }) {
133
- const connector = "|-";
134
- return (_jsx(Row, { status: _jsx(StatusText, { status: chunk.status }), files: _jsx(Text, { dimColor: true, children: ` ${connector} part ${chunk.pageNumber}` }), transactions: chunk.txCount, questions: chunk.questionsCount }));
135
- }
136
- function StatusText({ status }) {
137
- return STATUS_RENDER[status]();
138
- }
139
- function Row({ status, files, transactions, questions, }) {
140
- return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: COL.status, children: status }), _jsx(Box, { width: COL.files, children: files }), _jsx(Box, { width: COL.transactions, children: _jsx(Numeric, { n: transactions }) }), _jsx(Box, { width: COL.questions, children: _jsx(Numeric, { n: questions }) })] }));
141
- }
142
- const NUMERIC_RULES = [
143
- { when: (n) => n > 0, state: "present" },
144
- { when: () => true, state: "empty" },
145
- ];
146
- const NUMERIC_RENDER = {
147
- present: (n) => _jsx(Text, { children: n }),
148
- empty: () => (_jsx(Text, { color: "gray", dimColor: true, children: "-" })),
149
- };
150
- function Numeric({ n }) {
151
- return NUMERIC_RENDER[classify(n, NUMERIC_RULES)](n);
152
- }
153
- function truncateMiddle(s, width) {
154
- if (s.length <= width)
155
- return s;
156
- const keep = width - 1;
157
- const left = Math.ceil(keep / 2);
158
- const right = Math.floor(keep / 2);
159
- return s.slice(0, left) + "..." + s.slice(s.length - right);
157
+ const effectiveStatus = phase === "clarify" ? "clarify" : agg.status;
158
+ const marker = isExpanded ? "" : isCursor ? "▸" : " ";
159
+ const status = STATUS_COLOR[effectiveStatus](padRight(statusText(effectiveStatus, frame), COL.status));
160
+ const nameRaw = truncateMiddle(group.fileName, COL.files - 2);
161
+ const namePadded = padRight(nameRaw, COL.files);
162
+ const name = isCursor ? chalk.cyan.bold(namePadded) : chalk.dim(namePadded);
163
+ const tx = renderCount(agg.totalTx, COL.transactions);
164
+ const q = renderCount(agg.totalQuestions, COL.questions);
165
+ return `${marker} ${status}${name}${tx}${q}`;
166
+ }
167
+ function renderCount(n, width) {
168
+ const raw = n > 0 ? String(n) : "-";
169
+ const padded = padRight(raw, width);
170
+ return n > 0 ? padded : chalk.gray(padded);
171
+ }
172
+ const ChunkList = memo(function ChunkList({ chunks, frame, }) {
173
+ const sorted = Array.from(chunks.values()).sort((a, b) => a.pageNumber - b.pageNumber);
174
+ return (_jsx(Box, { flexDirection: "column", children: sorted.map((c) => (_jsx(Text, { children: renderChunkLine(c, frame) }, c.pageNumber))) }));
175
+ });
176
+ function renderChunkLine(c, frame) {
177
+ const status = STATUS_COLOR[c.status](padRight(statusText(c.status, frame), COL.status));
178
+ const part = chalk.dim(padRight(` |- part ${c.pageNumber}`, COL.files));
179
+ const tx = renderCount(c.txCount, COL.transactions);
180
+ const q = renderCount(c.questionsCount, COL.questions);
181
+ return ` ${status}${part}${tx}${q}`;
160
182
  }
161
183
  function useFileGroups(controller, files) {
162
184
  const [rows, setRows] = useState(() => seedRows(files));
@@ -1,5 +1,7 @@
1
1
  import { useEffect, useMemo, useState } from "react";
2
2
  import chalk from "chalk";
3
+ import { getActiveModel } from "../../../config.js";
4
+ import { getProvider } from "../../../ai/providers/index.js";
3
5
  const HINTS = [
4
6
  "try: what's my net worth?",
5
7
  "try: how many months of runway do I have?",
@@ -36,33 +38,23 @@ const HINTS = [
36
38
  export function useFooterText(db) {
37
39
  const [tick, setTick] = useState(0);
38
40
  const [hintIdx] = useState(() => Math.floor(Math.random() * HINTS.length));
41
+ const providerModel = `${getProvider().name}/${getActiveModel()}`;
39
42
  useEffect(() => {
40
43
  const id = setInterval(() => setTick((t) => t + 1), 60_000);
41
44
  return () => clearInterval(id);
42
45
  }, []);
43
46
  return useMemo(() => {
44
- const lastScan = db
45
- .prepare(`SELECT MAX(scanned_at) AS ts FROM scanned_files WHERE status = 'scanned'`)
47
+ const { n: fileCount } = db
48
+ .prepare(`SELECT COUNT(*) AS n FROM scanned_files WHERE status = 'scanned'`)
46
49
  .get();
47
- let scanStr = "";
48
- if (lastScan?.ts) {
49
- const diffMs = Date.now() - new Date(lastScan.ts + "Z").getTime();
50
- const mins = Math.floor(diffMs / 60000);
51
- if (mins < 1)
52
- scanStr = "scanned just now";
53
- else if (mins < 60)
54
- scanStr = `scanned ${mins}m ago`;
55
- else if (mins < 1440)
56
- scanStr = `scanned ${Math.floor(mins / 60)}h ago`;
57
- else
58
- scanStr = `scanned ${Math.floor(mins / 1440)}d ago`;
59
- }
60
50
  const idx = (hintIdx + tick) % HINTS.length;
61
- const parts = [`${chalk.cyan("<°(((><")}`];
62
- if (scanStr)
63
- parts.push(scanStr);
64
- parts.push(HINTS[idx]);
65
- parts.push("ctrl+c to exit");
66
- return parts.join(" | ");
67
- }, [db, tick, hintIdx]);
51
+ const parts = [
52
+ chalk.cyan("<°(((><"),
53
+ chalk.dim(providerModel),
54
+ chalk.dim(`${fileCount} file${fileCount === 1 ? "" : "s"}`),
55
+ chalk.dim(HINTS[idx]),
56
+ chalk.dim("ctrl+c to exit"),
57
+ ];
58
+ return parts.join(chalk.dim(" | "));
59
+ }, [db, tick, hintIdx, providerModel]);
68
60
  }
@@ -0,0 +1,2 @@
1
+ import type { Key } from "ink";
2
+ export declare function keyOf(input: string, key: Key): string;
@@ -0,0 +1,19 @@
1
+ const SPECIAL = [
2
+ [(k) => k.escape, "escape"],
3
+ [(k) => k.return, "return"],
4
+ [(k) => k.backspace, "backspace"],
5
+ [(k) => k.delete, "delete"],
6
+ [(k) => k.upArrow, "upArrow"],
7
+ [(k) => k.downArrow, "downArrow"],
8
+ [(k) => k.leftArrow, "leftArrow"],
9
+ [(k) => k.rightArrow, "rightArrow"],
10
+ [(k) => k.pageUp, "pageUp"],
11
+ [(k) => k.pageDown, "pageDown"],
12
+ [(k) => k.tab, "tab"],
13
+ ];
14
+ export function keyOf(input, key) {
15
+ for (const [pred, name] of SPECIAL)
16
+ if (pred(key))
17
+ return name;
18
+ return input;
19
+ }
@@ -4,8 +4,37 @@ export interface ScannedFileTotals {
4
4
  pending: number;
5
5
  failed: number;
6
6
  }
7
+ export interface ScannedFileRow {
8
+ id: string;
9
+ path: string;
10
+ file_hash: string;
11
+ mime: string;
12
+ status: "pending" | "scanned" | "failed";
13
+ scanned_at: string | null;
14
+ provider: string | null;
15
+ model: string | null;
16
+ error: string | null;
17
+ created_at: string;
18
+ }
7
19
  /**
8
20
  * Bucket the `scanned_files` table by its `status` enum. Missing buckets are
9
21
  * filled with 0 so callers can render a stable shape without null checks.
10
22
  */
11
23
  export declare function countScannedFiles(db: Database.Database): ScannedFileTotals;
24
+ export declare function listScannedFiles(db: Database.Database): ScannedFileRow[];
25
+ export declare function findScannedFileById(db: Database.Database, id: string): ScannedFileRow | null;
26
+ export interface DeleteScannedFileResult {
27
+ /** The deleted row, or null when no row matched the id. */
28
+ removed: ScannedFileRow | null;
29
+ /** Count of transaction rows that cascaded out. */
30
+ removedTransactions: number;
31
+ /** Count of question rows that cascaded out. */
32
+ removedQuestions: number;
33
+ }
34
+ /**
35
+ * Delete a `scanned_files` row by id. Cascades remove transactions
36
+ * (`transactions.source_file_id`) and questions (`questions.file_id`) via the
37
+ * schema's ON DELETE CASCADE. Cascaded counts are gathered before the DELETE
38
+ * so callers can report what disappeared.
39
+ */
40
+ export declare function deleteScannedFile(db: Database.Database, id: string): DeleteScannedFileResult;
@@ -14,3 +14,37 @@ export function countScannedFiles(db) {
14
14
  }
15
15
  return totals;
16
16
  }
17
+ export function listScannedFiles(db) {
18
+ return db
19
+ .prepare(`SELECT id, path, file_hash, mime, status, scanned_at, provider, model, error, created_at
20
+ FROM scanned_files
21
+ ORDER BY scanned_at DESC, created_at DESC`)
22
+ .all();
23
+ }
24
+ export function findScannedFileById(db, id) {
25
+ const row = db
26
+ .prepare(`SELECT id, path, file_hash, mime, status, scanned_at, provider, model, error, created_at
27
+ FROM scanned_files WHERE id = ?`)
28
+ .get(id);
29
+ return row ?? null;
30
+ }
31
+ /**
32
+ * Delete a `scanned_files` row by id. Cascades remove transactions
33
+ * (`transactions.source_file_id`) and questions (`questions.file_id`) via the
34
+ * schema's ON DELETE CASCADE. Cascaded counts are gathered before the DELETE
35
+ * so callers can report what disappeared.
36
+ */
37
+ export function deleteScannedFile(db, id) {
38
+ const removed = findScannedFileById(db, id);
39
+ if (!removed) {
40
+ return { removed: null, removedTransactions: 0, removedQuestions: 0 };
41
+ }
42
+ const removedTransactions = db
43
+ .prepare(`SELECT COUNT(*) AS n FROM transactions WHERE source_file_id = ?`)
44
+ .get(id).n;
45
+ const removedQuestions = db
46
+ .prepare(`SELECT COUNT(*) AS n FROM questions WHERE file_id = ?`)
47
+ .get(id).n;
48
+ db.prepare(`DELETE FROM scanned_files WHERE id = ?`).run(id);
49
+ return { removed, removedTransactions, removedQuestions };
50
+ }
@@ -22,12 +22,17 @@ export interface QuestionRow {
22
22
  prompt: string;
23
23
  options_json: string | null;
24
24
  context_json: string | null;
25
+ deferred_until: string | null;
25
26
  created_at: string;
26
27
  }
27
28
  export interface ClosedQuestion {
28
29
  prompt: string;
29
30
  kind: string | null;
30
31
  answer: string;
32
+ /** Stable signature pulled from the question's context_json. When set, the
33
+ * rule synthesizer keys the learned rule on this (so future questions with
34
+ * different prose but the same key match). When null, no rule is learned. */
35
+ rule_key: string | null;
31
36
  }
32
37
  /**
33
38
  * Insert a new questions row and flip the `has_question` boolean on whichever
@@ -53,10 +58,22 @@ export interface CountQuestionsScope {
53
58
  account_id?: string;
54
59
  kind?: string;
55
60
  scan_id?: string;
61
+ /** When true, count deferred rows too (default false — defer hides). */
62
+ includeDeferred?: boolean;
56
63
  }
57
64
  export declare function countQuestions(db: Database.Database, scope?: CountQuestionsScope): number;
58
65
  export interface ListQuestionsOptions {
59
66
  limit?: number;
60
67
  scanId?: string;
68
+ /** When true, include deferred rows in the result (default false). */
69
+ includeDeferred?: boolean;
61
70
  }
62
71
  export declare function listQuestions(db: Database.Database, opts?: ListQuestionsOptions): QuestionRow[];
72
+ /**
73
+ * Mark a question as deferred for `days` days from now. The default
74
+ * `listQuestions` / `countQuestions` filter hides deferred rows until the
75
+ * timestamp passes, so the clarifier won't re-encounter the question on the
76
+ * next run. Pass `includeDeferred: true` to those functions for an
77
+ * unfiltered view (e.g. for the rules / files browsers).
78
+ */
79
+ export declare function deferQuestion(db: Database.Database, id: string, days: number): boolean;
@@ -24,7 +24,7 @@ export function recordQuestion(db, input) {
24
24
  */
25
25
  export function closeQuestion(db, id, answer) {
26
26
  const row = db
27
- .prepare(`SELECT prompt, kind, transaction_id, account_id FROM questions WHERE id = ?`)
27
+ .prepare(`SELECT prompt, kind, transaction_id, account_id, context_json FROM questions WHERE id = ?`)
28
28
  .get(id);
29
29
  if (!row)
30
30
  return null;
@@ -33,7 +33,23 @@ export function closeQuestion(db, id, answer) {
33
33
  transaction_id: row.transaction_id,
34
34
  account_id: row.account_id,
35
35
  });
36
- return { prompt: row.prompt, kind: row.kind, answer };
36
+ return {
37
+ prompt: row.prompt,
38
+ kind: row.kind,
39
+ answer,
40
+ rule_key: extractRuleKey(row.context_json),
41
+ };
42
+ }
43
+ function extractRuleKey(contextJson) {
44
+ if (!contextJson)
45
+ return null;
46
+ try {
47
+ const parsed = JSON.parse(contextJson);
48
+ return typeof parsed?.rule_key === "string" ? parsed.rule_key : null;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
37
53
  }
38
54
  /**
39
55
  * Look up the transaction/account a question is attached to. Returns null when
@@ -65,6 +81,7 @@ function maybeClearHasQuestionFlags(db, target) {
65
81
  db.prepare(`UPDATE accounts SET has_question = 0 WHERE id = ?`).run(target.account_id);
66
82
  }
67
83
  }
84
+ const ACTIVE_DEFERRED_CLAUSE = "(deferred_until IS NULL OR deferred_until <= datetime('now'))";
68
85
  export function countQuestions(db, scope = {}) {
69
86
  const conditions = [];
70
87
  const params = [];
@@ -88,23 +105,44 @@ export function countQuestions(db, scope = {}) {
88
105
  conditions.push("scan_id = ?");
89
106
  params.push(scope.scan_id);
90
107
  }
108
+ if (!scope.includeDeferred)
109
+ conditions.push(ACTIVE_DEFERRED_CLAUSE);
91
110
  const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
92
111
  const row = db
93
112
  .prepare(`SELECT COUNT(*) AS n FROM questions ${where}`)
94
113
  .get(...params);
95
114
  return row.n;
96
115
  }
116
+ const ROW_COLUMNS = "id, scan_id, file_id, transaction_id, account_id, kind, prompt, options_json, context_json, deferred_until, created_at";
97
117
  export function listQuestions(db, opts = {}) {
98
118
  const capped = Math.min(Math.max(opts.limit ?? 200, 1), 1000);
119
+ const conditions = [];
120
+ const params = [];
99
121
  if (opts.scanId) {
100
- return db.prepare(`SELECT id, scan_id, file_id, transaction_id, account_id, kind, prompt, options_json, context_json, created_at
101
- FROM questions
102
- WHERE scan_id = ?
103
- ORDER BY created_at ASC
104
- LIMIT ?`).all(opts.scanId, capped);
122
+ conditions.push("scan_id = ?");
123
+ params.push(opts.scanId);
105
124
  }
106
- return db.prepare(`SELECT id, scan_id, file_id, transaction_id, account_id, kind, prompt, options_json, context_json, created_at
125
+ if (!opts.includeDeferred)
126
+ conditions.push(ACTIVE_DEFERRED_CLAUSE);
127
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
128
+ params.push(capped);
129
+ return db.prepare(`SELECT ${ROW_COLUMNS}
107
130
  FROM questions
131
+ ${where}
108
132
  ORDER BY created_at ASC
109
- LIMIT ?`).all(capped);
133
+ LIMIT ?`).all(...params);
134
+ }
135
+ /**
136
+ * Mark a question as deferred for `days` days from now. The default
137
+ * `listQuestions` / `countQuestions` filter hides deferred rows until the
138
+ * timestamp passes, so the clarifier won't re-encounter the question on the
139
+ * next run. Pass `includeDeferred: true` to those functions for an
140
+ * unfiltered view (e.g. for the rules / files browsers).
141
+ */
142
+ export function deferQuestion(db, id, days) {
143
+ const safeDays = Math.max(1, Math.floor(days));
144
+ const result = db
145
+ .prepare(`UPDATE questions SET deferred_until = datetime('now', ?) WHERE id = ?`)
146
+ .run(`+${safeDays} days`, id);
147
+ return result.changes > 0;
110
148
  }
@@ -0,0 +1,31 @@
1
+ import type Database from "libsql";
2
+ export interface Rule {
3
+ id: number;
4
+ kind: string;
5
+ key: string;
6
+ target: string;
7
+ evidence_count: number;
8
+ last_seen_at: string;
9
+ created_at: string;
10
+ }
11
+ export interface UpsertRuleInput {
12
+ kind: string;
13
+ key: string;
14
+ target: string;
15
+ }
16
+ /**
17
+ * Insert a rule keyed on (kind, key), or — if one already exists — bump
18
+ * `evidence_count`, refresh `last_seen_at`, and overwrite `target` with the
19
+ * latest answer. The deterministic clarifier pass looks rules up via the
20
+ * UNIQUE(kind, key) index, so this is the only write path that keeps the
21
+ * rule store sparse and indexed.
22
+ */
23
+ export declare function upsertRule(db: Database.Database, input: UpsertRuleInput): Rule;
24
+ export declare function findRule(db: Database.Database, kind: string, key: string): Rule | null;
25
+ export interface ListRulesOptions {
26
+ kind?: string;
27
+ limit?: number;
28
+ }
29
+ export declare function listRules(db: Database.Database, opts?: ListRulesOptions): Rule[];
30
+ export declare function countRules(db: Database.Database): number;
31
+ export declare function deleteRule(db: Database.Database, id: number): Rule | null;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Insert a rule keyed on (kind, key), or — if one already exists — bump
3
+ * `evidence_count`, refresh `last_seen_at`, and overwrite `target` with the
4
+ * latest answer. The deterministic clarifier pass looks rules up via the
5
+ * UNIQUE(kind, key) index, so this is the only write path that keeps the
6
+ * rule store sparse and indexed.
7
+ */
8
+ export function upsertRule(db, input) {
9
+ db.prepare(`INSERT INTO rules (kind, key, target)
10
+ VALUES (?, ?, ?)
11
+ ON CONFLICT(kind, key) DO UPDATE SET
12
+ target = excluded.target,
13
+ evidence_count = evidence_count + 1,
14
+ last_seen_at = datetime('now')`).run(input.kind, input.key, input.target);
15
+ const row = findRule(db, input.kind, input.key);
16
+ if (!row)
17
+ throw new Error(`upsertRule: row vanished after upsert (${input.kind}, ${input.key})`);
18
+ return row;
19
+ }
20
+ export function findRule(db, kind, key) {
21
+ const row = db
22
+ .prepare(`SELECT id, kind, key, target, evidence_count, last_seen_at, created_at
23
+ FROM rules WHERE kind = ? AND key = ?`)
24
+ .get(kind, key);
25
+ return row ?? null;
26
+ }
27
+ export function listRules(db, opts = {}) {
28
+ const limit = Math.min(Math.max(opts.limit ?? 500, 1), 5000);
29
+ if (opts.kind) {
30
+ return db
31
+ .prepare(`SELECT id, kind, key, target, evidence_count, last_seen_at, created_at
32
+ FROM rules WHERE kind = ?
33
+ ORDER BY last_seen_at DESC LIMIT ?`)
34
+ .all(opts.kind, limit);
35
+ }
36
+ return db
37
+ .prepare(`SELECT id, kind, key, target, evidence_count, last_seen_at, created_at
38
+ FROM rules
39
+ ORDER BY last_seen_at DESC LIMIT ?`)
40
+ .all(limit);
41
+ }
42
+ export function countRules(db) {
43
+ const row = db.prepare(`SELECT COUNT(*) AS n FROM rules`).get();
44
+ return row.n;
45
+ }
46
+ export function deleteRule(db, id) {
47
+ const row = db
48
+ .prepare(`SELECT id, kind, key, target, evidence_count, last_seen_at, created_at
49
+ FROM rules WHERE id = ?`)
50
+ .get(id);
51
+ if (!row)
52
+ return null;
53
+ db.prepare(`DELETE FROM rules WHERE id = ?`).run(id);
54
+ return row;
55
+ }
@@ -89,6 +89,39 @@ export declare function updatePosting(db: Database.Database, postingId: string,
89
89
  * the postings automatically.
90
90
  */
91
91
  export declare function deleteTransaction(db: Database.Database, transactionId: string): number;
92
+ export interface BulkUpdatePostingsFilter {
93
+ account_id?: string;
94
+ /** Case-insensitive substring match against `transactions.description`.
95
+ * Use multiple bulk calls for descriptor variants — there is no regex. */
96
+ description_contains?: string;
97
+ currency?: string;
98
+ from?: string;
99
+ to?: string;
100
+ merchant_id?: string;
101
+ }
102
+ export interface BulkUpdatePostingsSet {
103
+ account_id?: string;
104
+ memo?: string | null;
105
+ }
106
+ export interface BulkUpdatePostingsResult {
107
+ affected: number;
108
+ sample_posting_ids: string[];
109
+ }
110
+ /**
111
+ * Backfill primitive. Update every posting matching the filter in one SQL
112
+ * UPDATE, return the affected count plus a sample of ids so the caller (often
113
+ * an AI tool) can quote evidence back to the user.
114
+ *
115
+ * Refuses to run without at least one filter field (no "update everything"
116
+ * escape hatch) and without at least one set field. Also refuses a no-op
117
+ * recategorization where `set.account_id` equals `filter.account_id` —
118
+ * agents shouldn't waste tool calls on identity transforms.
119
+ *
120
+ * Safe-field policy mirrors `updatePosting`: account_id + memo only.
121
+ * Amount/currency corrections must go through delete + re-record to keep
122
+ * the transaction's debit=credit invariant intact.
123
+ */
124
+ export declare function bulkUpdatePostings(db: Database.Database, filter: BulkUpdatePostingsFilter, set: BulkUpdatePostingsSet): BulkUpdatePostingsResult;
92
125
  export interface DuplicateGroupTransaction {
93
126
  id: string;
94
127
  date: string;
@@ -170,3 +203,4 @@ export interface TransactionTotals {
170
203
  postings: number;
171
204
  }
172
205
  export declare function countTransactions(db: Database.Database): TransactionTotals;
206
+ export declare function countTransactionsBySourceFile(db: Database.Database, fileId: string): number;