plasalid 0.8.3 → 0.9.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 +5 -1
- 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/files.d.ts +7 -0
- package/dist/cli/commands/files.js +24 -0
- package/dist/cli/commands/rules.js +23 -20
- package/dist/cli/commands/scan.js +8 -3
- package/dist/cli/helper.d.ts +9 -1
- package/dist/cli/helper.js +17 -2
- package/dist/cli/index.js +12 -0
- package/dist/cli/ink/ChatApp.js +1 -1
- package/dist/cli/ink/FilesBrowser.d.ts +7 -0
- package/dist/cli/ink/FilesBrowser.js +103 -0
- package/dist/cli/ink/ListBrowser.d.ts +9 -1
- package/dist/cli/ink/ListBrowser.js +2 -2
- package/dist/cli/ink/PromptFrame.js +1 -1
- package/dist/cli/ink/ScanDashboard.js +90 -65
- package/dist/cli/ink/hooks/useFooterText.d.ts +1 -2
- package/dist/cli/ink/hooks/useFooterText.js +11 -24
- 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 +25 -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/package.json +1 -1
|
@@ -4,6 +4,7 @@ import { runScan } from "../../scanner/engine.js";
|
|
|
4
4
|
import { getActiveModel } from "../../config.js";
|
|
5
5
|
import { getProvider } from "../../ai/providers/index.js";
|
|
6
6
|
import { AbortedError } from "../../ai/errors.js";
|
|
7
|
+
import { countQuestions } from "../../db/queries/questions.js";
|
|
7
8
|
/** Show the cursor — always safe; mirrors the TTY mount-time hide. */
|
|
8
9
|
function restoreTerminal() {
|
|
9
10
|
if (process.stdout.isTTY)
|
|
@@ -268,9 +269,13 @@ function renderSummary(state) {
|
|
|
268
269
|
const r = state.clarifySummary;
|
|
269
270
|
if (r && r.total > 0) {
|
|
270
271
|
console.log(`Clarified ${r.clarified}/${r.total} questions.`);
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
272
|
+
}
|
|
273
|
+
// Show the total active backlog (excluding deferred rows) across the whole
|
|
274
|
+
// ledger, not just this scan — so the user sees questions accumulated from
|
|
275
|
+
// prior runs alongside this scan's leftovers.
|
|
276
|
+
const totalOpen = countQuestions(getDb());
|
|
277
|
+
if (totalOpen > 0) {
|
|
278
|
+
console.log(chalk.yellow(`${totalOpen} question(s) need your input — run ${chalk.cyan("plasalid clarify")} when ready.`));
|
|
274
279
|
}
|
|
275
280
|
if (state.errors.length > 0) {
|
|
276
281
|
console.log(chalk.yellow(`${state.errors.length} phase error(s):`));
|
package/dist/cli/helper.d.ts
CHANGED
|
@@ -3,9 +3,17 @@
|
|
|
3
3
|
* in the middle. Returns `s` unchanged when already short enough.
|
|
4
4
|
*/
|
|
5
5
|
export declare function truncateMiddle(s: string, max: number): string;
|
|
6
|
+
/**
|
|
7
|
+
* Count visible terminal cells. Each code point counts as 1, except Unicode
|
|
8
|
+
* combining marks (\p{M}) which stack on the preceding base char and count
|
|
9
|
+
* as 0. Does not handle East-Asian wide characters; revisit if CJK filenames
|
|
10
|
+
* appear in real ledgers.
|
|
11
|
+
*/
|
|
12
|
+
export declare function displayWidth(s: string): number;
|
|
6
13
|
/**
|
|
7
14
|
* Right-pad to a fixed visible width. Assumes `s` has no ANSI codes — callers
|
|
8
15
|
* working with colored strings should compose color around the padded value,
|
|
9
|
-
* not inside it.
|
|
16
|
+
* not inside it. Pads by display width so Thai (and other scripts with
|
|
17
|
+
* combining marks) stay aligned with surrounding columns.
|
|
10
18
|
*/
|
|
11
19
|
export declare function padRight(s: string, width: number): string;
|
package/dist/cli/helper.js
CHANGED
|
@@ -14,11 +14,26 @@ export function truncateMiddle(s, max) {
|
|
|
14
14
|
const tail = Math.floor(keep / 2);
|
|
15
15
|
return `${s.slice(0, head)}…${s.slice(s.length - tail)}`;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Count visible terminal cells. Each code point counts as 1, except Unicode
|
|
19
|
+
* combining marks (\p{M}) which stack on the preceding base char and count
|
|
20
|
+
* as 0. Does not handle East-Asian wide characters; revisit if CJK filenames
|
|
21
|
+
* appear in real ledgers.
|
|
22
|
+
*/
|
|
23
|
+
export function displayWidth(s) {
|
|
24
|
+
let w = 0;
|
|
25
|
+
for (const c of s)
|
|
26
|
+
if (!/^\p{M}$/u.test(c))
|
|
27
|
+
w++;
|
|
28
|
+
return w;
|
|
29
|
+
}
|
|
17
30
|
/**
|
|
18
31
|
* Right-pad to a fixed visible width. Assumes `s` has no ANSI codes — callers
|
|
19
32
|
* working with colored strings should compose color around the padded value,
|
|
20
|
-
* not inside it.
|
|
33
|
+
* not inside it. Pads by display width so Thai (and other scripts with
|
|
34
|
+
* combining marks) stay aligned with surrounding columns.
|
|
21
35
|
*/
|
|
22
36
|
export function padRight(s, width) {
|
|
23
|
-
|
|
37
|
+
const visual = displayWidth(s);
|
|
38
|
+
return visual >= width ? s : s + " ".repeat(width - visual);
|
|
24
39
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -119,6 +119,14 @@ program
|
|
|
119
119
|
const { runScanCommand } = await import("./commands/scan.js");
|
|
120
120
|
await runScanCommand({ regex: regexes[0], force: !!opts.force, parallel });
|
|
121
121
|
});
|
|
122
|
+
program
|
|
123
|
+
.command("files")
|
|
124
|
+
.description("Browse scanned files; press d to drop one and cascade-remove its data")
|
|
125
|
+
.action(async () => {
|
|
126
|
+
ensureConfigured();
|
|
127
|
+
const { showFiles } = await import("./commands/files.js");
|
|
128
|
+
await showFiles();
|
|
129
|
+
});
|
|
122
130
|
program
|
|
123
131
|
.command("rules")
|
|
124
132
|
.description("Browse the rules the system has learned (press d to delete)")
|
|
@@ -163,6 +171,10 @@ program.configureHelp({
|
|
|
163
171
|
name: "scan",
|
|
164
172
|
desc: "Scan new PDFs (optionally by regex; --force to re-scan)",
|
|
165
173
|
},
|
|
174
|
+
{
|
|
175
|
+
name: "files",
|
|
176
|
+
desc: "Browse scanned files; press d to drop one and cascade-remove its data",
|
|
177
|
+
},
|
|
166
178
|
{
|
|
167
179
|
name: "rules",
|
|
168
180
|
desc: "Browse the rules the system has learned (press d to delete)",
|
package/dist/cli/ink/ChatApp.js
CHANGED
|
@@ -17,7 +17,7 @@ const nextId = () => `t${++turnSeq}`;
|
|
|
17
17
|
export function ChatApp({ db, onboardingPrompt }) {
|
|
18
18
|
const { exit } = useApp();
|
|
19
19
|
const [turns, setTurns] = useState([]);
|
|
20
|
-
const footerText = useFooterText(
|
|
20
|
+
const footerText = useFooterText();
|
|
21
21
|
const ctrlC = useCtrlCExit();
|
|
22
22
|
const pushTurn = useCallback((t) => {
|
|
23
23
|
setTurns(prev => [...prev, t]);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
import { type ScannedFileRow } from "../../db/queries/files.js";
|
|
3
|
+
export interface FilesBrowserProps {
|
|
4
|
+
files: ScannedFileRow[];
|
|
5
|
+
db: Database.Database;
|
|
6
|
+
}
|
|
7
|
+
export declare function FilesBrowser({ files: initialFiles, db }: FilesBrowserProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import { Text } from "ink";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { padRight, truncateMiddle } from "../helper.js";
|
|
6
|
+
import { ListBrowser } from "./ListBrowser.js";
|
|
7
|
+
import { keyOf } from "./keys.js";
|
|
8
|
+
import { getDataDir } from "../../config.js";
|
|
9
|
+
import { deleteScannedFile, } from "../../db/queries/files.js";
|
|
10
|
+
import { countTransactionsBySourceFile } from "../../db/queries/transactions.js";
|
|
11
|
+
import { countQuestions } from "../../db/queries/questions.js";
|
|
12
|
+
const COL = {
|
|
13
|
+
status: 9,
|
|
14
|
+
provenance: 28,
|
|
15
|
+
scannedAt: 20,
|
|
16
|
+
};
|
|
17
|
+
const MIN_PATH_WIDTH = 16;
|
|
18
|
+
const MAX_PATH_WIDTH = 50;
|
|
19
|
+
const STATUS_COLOR = {
|
|
20
|
+
scanned: chalk.green,
|
|
21
|
+
failed: chalk.red,
|
|
22
|
+
pending: chalk.gray,
|
|
23
|
+
};
|
|
24
|
+
export function FilesBrowser({ files: initialFiles, db }) {
|
|
25
|
+
const [files, setFiles] = useState(initialFiles);
|
|
26
|
+
const [confirm, setConfirm] = useState(null);
|
|
27
|
+
const adapter = useMemo(() => {
|
|
28
|
+
const commitDelete = () => {
|
|
29
|
+
if (!confirm)
|
|
30
|
+
return;
|
|
31
|
+
deleteScannedFile(db, confirm.fileId);
|
|
32
|
+
setFiles(prev => prev.filter(f => f.id !== confirm.fileId));
|
|
33
|
+
setConfirm(null);
|
|
34
|
+
};
|
|
35
|
+
const cancelConfirm = () => setConfirm(null);
|
|
36
|
+
const CONFIRM_KEYS = {
|
|
37
|
+
y: commitDelete,
|
|
38
|
+
n: cancelConfirm,
|
|
39
|
+
escape: cancelConfirm,
|
|
40
|
+
};
|
|
41
|
+
const BROWSE_KEYS = {
|
|
42
|
+
d: (cursorItem) => {
|
|
43
|
+
if (!cursorItem)
|
|
44
|
+
return false;
|
|
45
|
+
setConfirm({
|
|
46
|
+
fileId: cursorItem.id,
|
|
47
|
+
path: cursorItem.path,
|
|
48
|
+
cascadeTx: countTransactionsBySourceFile(db, cursorItem.id),
|
|
49
|
+
// includeDeferred: true so the count matches what ON DELETE CASCADE will actually drop.
|
|
50
|
+
cascadeQuestions: countQuestions(db, { file_id: cursorItem.id, includeDeferred: true }),
|
|
51
|
+
});
|
|
52
|
+
return true;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
title: "Scanned files",
|
|
57
|
+
items: files,
|
|
58
|
+
getId: f => f.id,
|
|
59
|
+
renderRow: (f, ctx) => renderFileRow(f, ctx.isCursor, ctx.cols),
|
|
60
|
+
matches: (f, needle) => f.path.toLowerCase().includes(needle) ||
|
|
61
|
+
(f.provider ?? "").toLowerCase().includes(needle) ||
|
|
62
|
+
(f.model ?? "").toLowerCase().includes(needle),
|
|
63
|
+
emptyMessage: "No scanned files match the current filter.",
|
|
64
|
+
summary: confirm ? (_jsx(Text, { color: "yellow", children: `Delete ${truncateMiddle(confirm.path, 60)}? (y/n) Cascade removes ${confirm.cascadeTx} transaction(s) and ${confirm.cascadeQuestions} question(s).` })) : undefined,
|
|
65
|
+
onKey: (input, key, { cursorItem }) => {
|
|
66
|
+
const k = keyOf(input, key).toLowerCase();
|
|
67
|
+
if (confirm !== null) {
|
|
68
|
+
CONFIRM_KEYS[k]?.();
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
return BROWSE_KEYS[k]?.(cursorItem) ?? false;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}, [files, confirm, db]);
|
|
75
|
+
return _jsx(ListBrowser, { adapter: adapter });
|
|
76
|
+
}
|
|
77
|
+
function renderFileRow(f, isCursor, cols) {
|
|
78
|
+
const marker = isCursor ? "▸" : " ";
|
|
79
|
+
const status = STATUS_COLOR[f.status](padRight(f.status, COL.status));
|
|
80
|
+
const provenanceRaw = f.provider && f.model ? `${f.provider}/${f.model}` : f.status === "failed" ? "(failed)" : "(not stamped)";
|
|
81
|
+
const provenance = chalk.dim(padRight(truncateMiddle(provenanceRaw, COL.provenance), COL.provenance));
|
|
82
|
+
const scannedAtRaw = f.scanned_at ?? "—";
|
|
83
|
+
const scannedAt = chalk.dim(padRight(scannedAtRaw, COL.scannedAt));
|
|
84
|
+
// Layout: "M status(9) path(flex) provenance(28) scannedAt(20)"
|
|
85
|
+
const fixedWidth = 1 + 1 + COL.status + 2 + 2 + COL.provenance + 2 + COL.scannedAt;
|
|
86
|
+
const pathBudget = Math.max(MIN_PATH_WIDTH, Math.min(MAX_PATH_WIDTH, cols - fixedWidth - 2));
|
|
87
|
+
const pathRaw = truncateMiddle(relativeFromDataDir(f.path), pathBudget);
|
|
88
|
+
const pathPadded = padRight(pathRaw, pathBudget);
|
|
89
|
+
const path = isCursor ? chalk.cyan.bold(pathPadded) : pathPadded;
|
|
90
|
+
return `${marker} ${status} ${path} ${provenance} ${scannedAt}`;
|
|
91
|
+
}
|
|
92
|
+
/** Strip the configured data-dir prefix so the path column shows just the
|
|
93
|
+
* meaningful tail (subdirs + filename). Falls back to the absolute path
|
|
94
|
+
* for files that somehow live outside the data dir. */
|
|
95
|
+
function relativeFromDataDir(absolutePath) {
|
|
96
|
+
const dataDir = getDataDir().replace(/\/+$/, "");
|
|
97
|
+
if (absolutePath === dataDir)
|
|
98
|
+
return absolutePath;
|
|
99
|
+
const prefix = dataDir + "/";
|
|
100
|
+
return absolutePath.startsWith(prefix)
|
|
101
|
+
? absolutePath.slice(prefix.length)
|
|
102
|
+
: absolutePath;
|
|
103
|
+
}
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import { type Key } from "ink";
|
|
3
3
|
export interface ListBrowserAdapter<T> {
|
|
4
|
-
title
|
|
4
|
+
/** Title row "{title} · N results · {filterSummary} · cursor/N". Omit to
|
|
5
|
+
* hide the title row and its separator rule entirely — useful when the
|
|
6
|
+
* adapter supplies its own `headerNode` and the auto-title would be
|
|
7
|
+
* redundant (e.g. ScanDashboard). */
|
|
8
|
+
title?: string;
|
|
5
9
|
filterSummary?: string;
|
|
10
|
+
/** Optional fixed chrome rendered above the title row. Use this when the
|
|
11
|
+
* list needs a multi-line header (e.g. a pipeline status indicator plus
|
|
12
|
+
* column labels) that should not scroll with the items. */
|
|
13
|
+
headerNode?: ReactNode;
|
|
6
14
|
items: T[];
|
|
7
15
|
getId: (item: T) => string;
|
|
8
16
|
/** Returns the row as a single ANSI-colored string for the given context. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { memo, useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
4
4
|
import { keyOf } from "./keys.js";
|
|
@@ -124,7 +124,7 @@ export function ListBrowser({ adapter }) {
|
|
|
124
124
|
const ruleWidth = Math.min(cols, 120);
|
|
125
125
|
const visibleEnd = Math.min(filtered.length, scrollOffset + effectiveViewportSize);
|
|
126
126
|
const visible = filtered.slice(scrollOffset, visibleEnd);
|
|
127
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: adapter.title }), _jsx(Text, { dimColor: true, children: ` · ${filtered.length} results` }), adapter.filterSummary ? _jsx(Text, { dimColor: true, children: ` · ${adapter.filterSummary}` }) : null, filtered.length > viewportSize ? (_jsx(Text, { dimColor: true, children: ` · ${Math.min(cursor + 1, filtered.length)}/${filtered.length}` })) : null] }), _jsx(Text, { dimColor: true, children: "─".repeat(ruleWidth) }), filtered.length === 0 ? (_jsx(Text, { color: "yellow", children: adapter.emptyMessage ?? "No results match the current filter." })) : (_jsx(Box, { flexDirection: "column", children: visible.map((item, i) => {
|
|
127
|
+
return (_jsxs(Box, { flexDirection: "column", children: [adapter.headerNode, adapter.title ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: adapter.title }), _jsx(Text, { dimColor: true, children: ` · ${filtered.length} results` }), adapter.filterSummary ? _jsx(Text, { dimColor: true, children: ` · ${adapter.filterSummary}` }) : null, filtered.length > viewportSize ? (_jsx(Text, { dimColor: true, children: ` · ${Math.min(cursor + 1, filtered.length)}/${filtered.length}` })) : null] }), _jsx(Text, { dimColor: true, children: "─".repeat(ruleWidth) })] })) : null, filtered.length === 0 ? (_jsx(Text, { color: "yellow", children: adapter.emptyMessage ?? "No results match the current filter." })) : (_jsx(Box, { flexDirection: "column", children: visible.map((item, i) => {
|
|
128
128
|
const idx = scrollOffset + i;
|
|
129
129
|
const isCursor = idx === cursor;
|
|
130
130
|
const id = adapter.getId(item);
|
|
@@ -7,5 +7,5 @@ export function PromptFrame({ buffer, footerText, showCaret, banner }) {
|
|
|
7
7
|
const { stdout } = useStdout();
|
|
8
8
|
const cols = stdout?.columns || 80;
|
|
9
9
|
const rule = chalk.dim("─".repeat(cols));
|
|
10
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: rule }), _jsx(TextInput, { buffer: buffer, prompt: chalk.dim("❯ "), showCaret: showCaret }), _jsx(Text, { children: rule }), _jsx(Text, { children:
|
|
10
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: rule }), _jsx(TextInput, { buffer: buffer, prompt: chalk.dim("❯ "), showCaret: showCaret }), _jsx(Text, { children: rule }), _jsx(Text, { children: ` ${footerText}` }), banner ? _jsx(Text, { children: banner }) : null] }));
|
|
11
11
|
}
|
|
@@ -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] })),
|
|
@@ -87,19 +103,34 @@ function Header({ phase }) {
|
|
|
87
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")] }));
|
|
88
104
|
}
|
|
89
105
|
function ColumnHeader() {
|
|
90
|
-
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" }) })] }));
|
|
91
|
-
}
|
|
92
|
-
function
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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,
|
|
103
134
|
};
|
|
104
135
|
const FILE_STATUS_RULES = [
|
|
105
136
|
{ when: ({ finished, total }) => finished < total, state: "scanning" },
|
|
@@ -120,40 +151,34 @@ function aggregate(chunks, total) {
|
|
|
120
151
|
const status = classify({ finished: done + failed, failed, total }, FILE_STATUS_RULES);
|
|
121
152
|
return { totalTx, totalQuestions, status };
|
|
122
153
|
}
|
|
123
|
-
function
|
|
124
|
-
const chunks = Array.from(group.chunks.values())
|
|
154
|
+
function renderFileRow(group, isCursor, isExpanded, frame, phase) {
|
|
155
|
+
const chunks = Array.from(group.chunks.values());
|
|
125
156
|
const agg = aggregate(chunks, group.totalChunks);
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return
|
|
135
|
-
}
|
|
136
|
-
function
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (s.length <= width)
|
|
152
|
-
return s;
|
|
153
|
-
const keep = width - 1;
|
|
154
|
-
const left = Math.ceil(keep / 2);
|
|
155
|
-
const right = Math.floor(keep / 2);
|
|
156
|
-
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}`;
|
|
157
182
|
}
|
|
158
183
|
function useFileGroups(controller, files) {
|
|
159
184
|
const [rows, setRows] = useState(() => seedRows(files));
|
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export declare function useFooterText(db: Database.Database): string;
|
|
1
|
+
export declare function useFooterText(): string;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState } from "react";
|
|
2
2
|
import chalk from "chalk";
|
|
3
|
+
import { getProvider } from "../../../ai/providers/index.js";
|
|
3
4
|
const HINTS = [
|
|
4
5
|
"try: what's my net worth?",
|
|
5
6
|
"try: how many months of runway do I have?",
|
|
@@ -33,36 +34,22 @@ const HINTS = [
|
|
|
33
34
|
"try: avalanche or snowball — what's faster?",
|
|
34
35
|
"try: am I paying more than the minimum?",
|
|
35
36
|
];
|
|
36
|
-
export function useFooterText(
|
|
37
|
+
export function useFooterText() {
|
|
37
38
|
const [tick, setTick] = useState(0);
|
|
38
39
|
const [hintIdx] = useState(() => Math.floor(Math.random() * HINTS.length));
|
|
40
|
+
const providerName = getProvider().name;
|
|
39
41
|
useEffect(() => {
|
|
40
42
|
const id = setInterval(() => setTick((t) => t + 1), 60_000);
|
|
41
43
|
return () => clearInterval(id);
|
|
42
44
|
}, []);
|
|
43
45
|
return useMemo(() => {
|
|
44
|
-
const lastScan = db
|
|
45
|
-
.prepare(`SELECT MAX(scanned_at) AS ts FROM scanned_files WHERE status = 'scanned'`)
|
|
46
|
-
.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
46
|
const idx = (hintIdx + tick) % HINTS.length;
|
|
61
|
-
const parts = [
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
47
|
+
const parts = [
|
|
48
|
+
chalk.cyan("<°(((><"),
|
|
49
|
+
chalk.dim(HINTS[idx]),
|
|
50
|
+
chalk.dim(providerName),
|
|
51
|
+
chalk.dim("ctrl+c to exit"),
|
|
52
|
+
];
|
|
53
|
+
return parts.join(chalk.dim(" | "));
|
|
54
|
+
}, [tick, hintIdx, providerName]);
|
|
68
55
|
}
|
|
@@ -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
|
+
}
|