plasalid 0.8.3 → 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 (52) 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/files.d.ts +7 -0
  13. package/dist/cli/commands/files.js +24 -0
  14. package/dist/cli/commands/rules.js +23 -20
  15. package/dist/cli/commands/scan.js +8 -3
  16. package/dist/cli/helper.d.ts +9 -1
  17. package/dist/cli/helper.js +17 -2
  18. package/dist/cli/index.js +12 -0
  19. package/dist/cli/ink/FilesBrowser.d.ts +7 -0
  20. package/dist/cli/ink/FilesBrowser.js +103 -0
  21. package/dist/cli/ink/ListBrowser.d.ts +9 -1
  22. package/dist/cli/ink/ListBrowser.js +2 -2
  23. package/dist/cli/ink/PromptFrame.js +1 -1
  24. package/dist/cli/ink/ScanDashboard.js +90 -65
  25. package/dist/cli/ink/hooks/useFooterText.js +14 -22
  26. package/dist/db/queries/files.d.ts +29 -0
  27. package/dist/db/queries/files.js +34 -0
  28. package/dist/db/queries/questions.d.ts +17 -0
  29. package/dist/db/queries/questions.js +47 -9
  30. package/dist/db/queries/rules.d.ts +31 -0
  31. package/dist/db/queries/rules.js +55 -0
  32. package/dist/db/queries/transactions.d.ts +34 -0
  33. package/dist/db/queries/transactions.js +86 -0
  34. package/dist/db/schema.js +17 -0
  35. package/dist/scanner/clarifier-memory.d.ts +15 -3
  36. package/dist/scanner/clarifier-memory.js +38 -17
  37. package/dist/scanner/clarifier.d.ts +2 -1
  38. package/dist/scanner/clarifier.js +40 -26
  39. package/dist/scanner/commit-pipeline.d.ts +56 -0
  40. package/dist/scanner/commit-pipeline.js +204 -0
  41. package/dist/scanner/committer.d.ts +56 -0
  42. package/dist/scanner/committer.js +204 -0
  43. package/dist/scanner/parse.js +25 -7
  44. package/dist/scanner/recurrence-pipeline.d.ts +28 -0
  45. package/dist/scanner/recurrence-pipeline.js +126 -0
  46. package/dist/scanner/recurrence.d.ts +28 -0
  47. package/dist/scanner/recurrence.js +155 -0
  48. package/dist/scanner/rule-keys.d.ts +13 -0
  49. package/dist/scanner/rule-keys.js +28 -0
  50. package/dist/scanner/rules.d.ts +13 -0
  51. package/dist/scanner/rules.js +28 -0
  52. package/package.json +1 -1
@@ -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;
@@ -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
- return s.length >= width ? s : s + " ".repeat(width - s.length);
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)",
@@ -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: string;
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: chalk.dim(` ${footerText}`) }), banner ? _jsx(Text, { children: banner }) : null] }));
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, 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 vision capability." }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "by the way, user 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." })] })] }));
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 Divider({ width }) {
93
- return _jsx(Text, { dimColor: true, children: "─".repeat(width) });
94
- }
95
- const spin = (label) => () => (_jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " ", label] }));
96
- const STATUS_RENDER = {
97
- queued: () => _jsx(Text, { color: "gray", children: "queued" }),
98
- running: spin("running"),
99
- scanning: spin("scanning"),
100
- done: () => _jsx(Text, { color: "green", children: "\u2713 done" }),
101
- failed: () => _jsx(Text, { color: "red", children: "failed" }),
102
- 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,
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 FileGroupView({ group }) {
124
- 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());
125
156
  const agg = aggregate(chunks, group.totalChunks);
126
- const fileName = `> ${truncateMiddle(group.fileName, COL.files - 2)}`;
127
- 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)))] }));
128
- }
129
- function ChunkRow({ chunk }) {
130
- const connector = "|-";
131
- 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 }));
132
- }
133
- function StatusText({ status }) {
134
- return STATUS_RENDER[status]();
135
- }
136
- function Row({ status, files, transactions, questions, }) {
137
- 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 }) })] }));
138
- }
139
- const NUMERIC_RULES = [
140
- { when: (n) => n > 0, state: "present" },
141
- { when: () => true, state: "empty" },
142
- ];
143
- const NUMERIC_RENDER = {
144
- present: (n) => _jsx(Text, { children: n }),
145
- empty: () => (_jsx(Text, { color: "gray", dimColor: true, children: "-" })),
146
- };
147
- function Numeric({ n }) {
148
- return NUMERIC_RENDER[classify(n, NUMERIC_RULES)](n);
149
- }
150
- function truncateMiddle(s, width) {
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,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
  }
@@ -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;