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.
- package/README.md +4 -0
- package/dist/ai/personas.js +29 -6
- package/dist/ai/prompt-sections.d.ts +10 -0
- package/dist/ai/prompt-sections.js +29 -0
- package/dist/ai/system-prompt.js +10 -6
- package/dist/ai/tools/clarify.js +35 -0
- package/dist/ai/tools/common.js +3 -2
- package/dist/ai/tools/index.js +6 -3
- package/dist/ai/tools/ingest.js +47 -35
- package/dist/ai/tools/mutate.d.ts +2 -0
- package/dist/ai/tools/mutate.js +81 -0
- package/dist/cli/commands/accounts.d.ts +1 -4
- package/dist/cli/commands/accounts.js +12 -101
- package/dist/cli/commands/files.d.ts +7 -0
- package/dist/cli/commands/files.js +24 -0
- package/dist/cli/commands/rules.d.ts +4 -12
- package/dist/cli/commands/rules.js +33 -67
- package/dist/cli/commands/scan.js +14 -12
- package/dist/cli/commands/status.js +5 -3
- package/dist/cli/commands/transactions.d.ts +0 -2
- package/dist/cli/commands/transactions.js +10 -63
- package/dist/cli/format.js +22 -32
- package/dist/cli/helper.d.ts +9 -1
- package/dist/cli/helper.js +17 -2
- package/dist/cli/index.js +37 -32
- package/dist/cli/ink/FilesBrowser.d.ts +7 -0
- package/dist/cli/ink/FilesBrowser.js +103 -0
- package/dist/cli/ink/ListBrowser.d.ts +16 -1
- package/dist/cli/ink/ListBrowser.js +36 -49
- package/dist/cli/ink/PromptFrame.js +1 -1
- package/dist/cli/ink/RulesBrowser.d.ts +7 -0
- package/dist/cli/ink/RulesBrowser.js +67 -0
- package/dist/cli/ink/ScanDashboard.js +90 -68
- package/dist/cli/ink/hooks/useFooterText.js +14 -22
- package/dist/cli/ink/keys.d.ts +2 -0
- package/dist/cli/ink/keys.js +19 -0
- package/dist/db/queries/files.d.ts +29 -0
- package/dist/db/queries/files.js +34 -0
- package/dist/db/queries/questions.d.ts +17 -0
- package/dist/db/queries/questions.js +47 -9
- package/dist/db/queries/rules.d.ts +31 -0
- package/dist/db/queries/rules.js +55 -0
- package/dist/db/queries/transactions.d.ts +34 -0
- package/dist/db/queries/transactions.js +86 -0
- package/dist/db/schema.js +17 -0
- package/dist/scanner/clarifier-memory.d.ts +15 -3
- package/dist/scanner/clarifier-memory.js +38 -17
- package/dist/scanner/clarifier.d.ts +2 -1
- package/dist/scanner/clarifier.js +40 -26
- package/dist/scanner/commit-pipeline.d.ts +56 -0
- package/dist/scanner/commit-pipeline.js +204 -0
- package/dist/scanner/committer.d.ts +56 -0
- package/dist/scanner/committer.js +204 -0
- package/dist/scanner/parse.js +27 -7
- package/dist/scanner/recurrence-pipeline.d.ts +28 -0
- package/dist/scanner/recurrence-pipeline.js +126 -0
- package/dist/scanner/recurrence.d.ts +28 -0
- package/dist/scanner/recurrence.js +155 -0
- package/dist/scanner/rule-keys.d.ts +13 -0
- package/dist/scanner/rule-keys.js +28 -0
- package/dist/scanner/rules.d.ts +13 -0
- package/dist/scanner/rules.js +28 -0
- package/dist/scanner/worker.js +4 -2
- 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
|
|
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
|
|
37
|
-
|
|
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: "
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
127
|
-
const chunks = Array.from(group.chunks.values())
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
return
|
|
138
|
-
}
|
|
139
|
-
function
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
45
|
-
.prepare(`SELECT
|
|
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 = [
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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,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;
|
package/dist/db/queries/files.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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(
|
|
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;
|