plasalid 0.7.1 → 0.7.2

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 (141) hide show
  1. package/README.md +2 -2
  2. package/dist/ai/agent.d.ts +6 -7
  3. package/dist/ai/agent.js +27 -11
  4. package/dist/ai/personas.js +48 -46
  5. package/dist/ai/system-prompt.js +1 -1
  6. package/dist/ai/tools/account-mutex.d.ts +1 -0
  7. package/dist/ai/tools/account-mutex.js +16 -0
  8. package/dist/ai/tools/index.js +4 -12
  9. package/dist/ai/tools/ingest.d.ts +1 -1
  10. package/dist/ai/tools/ingest.js +282 -242
  11. package/dist/ai/tools/merchants.js +1 -28
  12. package/dist/ai/tools/read.js +8 -8
  13. package/dist/ai/tools/record.js +3 -36
  14. package/dist/ai/tools/resolve.js +25 -22
  15. package/dist/ai/tools/scan.js +0 -1
  16. package/dist/ai/tools/types.d.ts +14 -21
  17. package/dist/cli/commands/record.js +1 -82
  18. package/dist/cli/commands/resolve.d.ts +5 -2
  19. package/dist/cli/commands/resolve.js +36 -5
  20. package/dist/cli/commands/revert.js +4 -2
  21. package/dist/cli/commands/rules.js +2 -2
  22. package/dist/cli/commands/scan.js +199 -128
  23. package/dist/cli/commands/status.js +5 -5
  24. package/dist/cli/index.js +8 -29
  25. package/dist/cli/ink/ScanDashboard.d.ts +49 -0
  26. package/dist/cli/ink/ScanDashboard.js +214 -0
  27. package/dist/cli/ink/scan_dashboard.d.ts +40 -25
  28. package/dist/cli/ink/scan_dashboard.js +139 -44
  29. package/dist/db/queries/account-balance.d.ts +1 -1
  30. package/dist/db/queries/questions.d.ts +62 -0
  31. package/dist/db/queries/questions.js +110 -0
  32. package/dist/db/queries/transactions.d.ts +1 -1
  33. package/dist/db/queries/unknowns.d.ts +17 -15
  34. package/dist/db/queries/unknowns.js +35 -39
  35. package/dist/db/schema.js +6 -28
  36. package/dist/scanner/audit/auditor.d.ts +31 -0
  37. package/dist/scanner/audit/auditor.js +72 -0
  38. package/dist/scanner/audit/engine.d.ts +10 -0
  39. package/dist/scanner/audit/engine.js +98 -0
  40. package/dist/scanner/audit/eventBus.d.ts +60 -0
  41. package/dist/scanner/audit/eventBus.js +35 -0
  42. package/dist/scanner/audit/passes/index.d.ts +11 -0
  43. package/dist/scanner/audit/passes/index.js +9 -0
  44. package/dist/scanner/audit/passes/types.d.ts +23 -0
  45. package/dist/scanner/audit/passes/types.js +1 -0
  46. package/dist/scanner/audit/types.d.ts +27 -0
  47. package/dist/scanner/audit/types.js +1 -0
  48. package/dist/scanner/auditor.d.ts +51 -0
  49. package/dist/scanner/auditor.js +80 -0
  50. package/dist/scanner/buffer/engine.d.ts +9 -0
  51. package/dist/scanner/buffer/engine.js +110 -0
  52. package/dist/scanner/buffer/sharedBuffer.d.ts +78 -0
  53. package/dist/scanner/buffer/sharedBuffer.js +130 -0
  54. package/dist/scanner/buffer/types.d.ts +67 -0
  55. package/dist/scanner/buffer/types.js +1 -0
  56. package/dist/scanner/buffer.d.ts +45 -38
  57. package/dist/scanner/buffer.js +93 -61
  58. package/dist/scanner/bus/engine.d.ts +11 -0
  59. package/dist/scanner/bus/engine.js +42 -0
  60. package/dist/scanner/bus/types.d.ts +53 -0
  61. package/dist/scanner/bus/types.js +1 -0
  62. package/dist/scanner/bus.d.ts +38 -0
  63. package/dist/scanner/bus.js +37 -0
  64. package/dist/scanner/chunk-worker.d.ts +19 -0
  65. package/dist/scanner/chunk-worker.js +67 -0
  66. package/dist/scanner/chunkWorker.d.ts +20 -0
  67. package/dist/scanner/chunkWorker.js +59 -0
  68. package/dist/scanner/chunker/chunker.d.ts +7 -0
  69. package/dist/scanner/chunker/chunker.js +60 -0
  70. package/dist/scanner/chunker.d.ts +7 -0
  71. package/dist/scanner/chunker.js +60 -0
  72. package/dist/scanner/converge.d.ts +29 -0
  73. package/dist/scanner/converge.js +15 -0
  74. package/dist/scanner/decrypt.d.ts +10 -0
  75. package/dist/scanner/decrypt.js +80 -0
  76. package/dist/scanner/engine/scanEngine.d.ts +24 -0
  77. package/dist/scanner/engine/scanEngine.js +87 -0
  78. package/dist/scanner/engine/types.d.ts +90 -0
  79. package/dist/scanner/engine/types.js +1 -0
  80. package/dist/scanner/engine.d.ts +90 -0
  81. package/dist/scanner/engine.js +84 -0
  82. package/dist/scanner/file-worker.d.ts +33 -0
  83. package/dist/scanner/file-worker.js +28 -0
  84. package/dist/scanner/fileWorker.d.ts +33 -0
  85. package/dist/scanner/fileWorker.js +22 -0
  86. package/dist/scanner/hooks/types.d.ts +25 -0
  87. package/dist/scanner/hooks/types.js +1 -0
  88. package/dist/scanner/hooks.d.ts +23 -0
  89. package/dist/scanner/hooks.js +1 -0
  90. package/dist/scanner/parse.d.ts +10 -0
  91. package/dist/scanner/parse.js +47 -0
  92. package/dist/scanner/passes/index.d.ts +8 -0
  93. package/dist/scanner/passes/index.js +6 -0
  94. package/dist/scanner/passes/types.d.ts +22 -0
  95. package/dist/scanner/passes/types.js +1 -0
  96. package/dist/scanner/pdf/chunker.d.ts +7 -0
  97. package/dist/scanner/pdf/chunker.js +60 -0
  98. package/dist/scanner/pdf/password-store.d.ts +34 -0
  99. package/dist/scanner/pdf/password-store.js +83 -0
  100. package/dist/scanner/pdf/pdf-unlock.d.ts +17 -0
  101. package/dist/scanner/pdf/pdf-unlock.js +50 -0
  102. package/dist/scanner/pdf/pdf.d.ts +17 -0
  103. package/dist/scanner/pdf/pdf.js +36 -0
  104. package/dist/scanner/pdf/state-machine.d.ts +60 -0
  105. package/dist/scanner/pdf/state-machine.js +64 -0
  106. package/dist/scanner/pdf/unlock.d.ts +22 -0
  107. package/dist/scanner/pdf/unlock.js +121 -0
  108. package/dist/scanner/phase-decrypt.d.ts +10 -0
  109. package/dist/scanner/phase-decrypt.js +80 -0
  110. package/dist/scanner/phase-parse.d.ts +10 -0
  111. package/dist/scanner/phase-parse.js +46 -0
  112. package/dist/scanner/phases/chunk.d.ts +8 -0
  113. package/dist/scanner/phases/chunk.js +13 -0
  114. package/dist/scanner/phases/commit.d.ts +12 -0
  115. package/dist/scanner/phases/commit.js +140 -0
  116. package/dist/scanner/phases/decrypt.d.ts +10 -0
  117. package/dist/scanner/phases/decrypt.js +80 -0
  118. package/dist/scanner/phases/parse.d.ts +10 -0
  119. package/dist/scanner/phases/parse.js +46 -0
  120. package/dist/scanner/phases/resolve.d.ts +10 -0
  121. package/dist/scanner/phases/resolve.js +17 -0
  122. package/dist/scanner/phases/review.d.ts +10 -0
  123. package/dist/scanner/phases/review.js +12 -0
  124. package/dist/scanner/progress.d.ts +14 -0
  125. package/dist/scanner/progress.js +21 -0
  126. package/dist/scanner/resolver-memory.d.ts +8 -0
  127. package/dist/scanner/resolver-memory.js +24 -0
  128. package/dist/scanner/resolver.d.ts +39 -0
  129. package/dist/scanner/resolver.js +196 -0
  130. package/dist/scanner/result.d.ts +17 -0
  131. package/dist/scanner/result.js +19 -0
  132. package/dist/scanner/run-passes.d.ts +30 -0
  133. package/dist/scanner/run-passes.js +15 -0
  134. package/dist/scanner/unlock.js +1 -1
  135. package/dist/scanner/worker.d.ts +19 -0
  136. package/dist/scanner/worker.js +67 -0
  137. package/dist/scanner/workers/chunkWorker.d.ts +20 -0
  138. package/dist/scanner/workers/chunkWorker.js +65 -0
  139. package/dist/scanner/workers/fileWorker.d.ts +32 -0
  140. package/dist/scanner/workers/fileWorker.js +22 -0
  141. package/package.json +1 -1
@@ -0,0 +1,214 @@
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";
4
+ import Spinner from "ink-spinner";
5
+ export function createScanDashboardController() {
6
+ const subscribers = new Set();
7
+ return {
8
+ publish(event) {
9
+ for (const sub of subscribers)
10
+ sub(event);
11
+ },
12
+ subscribe(handler) {
13
+ subscribers.add(handler);
14
+ return () => {
15
+ subscribers.delete(handler);
16
+ };
17
+ },
18
+ };
19
+ }
20
+ /** First matching rule wins. The final rule should be a catch-all. */
21
+ function classify(input, rules) {
22
+ for (const r of rules)
23
+ if (r.when(input))
24
+ return r.state;
25
+ throw new Error("classify: no rule matched (missing catch-all?)");
26
+ }
27
+ const COL = {
28
+ status: 14,
29
+ files: 34,
30
+ transactions: 13,
31
+ questions: 10,
32
+ };
33
+ /**
34
+ * Tree-layout scan dashboard. Header carries the only animated element (one
35
+ * `<Spinner>`). All other status indicators are static glyphs that only
36
+ * redraw when their data changes.
37
+ */
38
+ export function ScanDashboard(props) {
39
+ const rows = useFileGroups(props.controller, props.files);
40
+ const phase = usePhase(props.controller);
41
+ const ruleWidth = useRuleWidth();
42
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { phase: phase }), _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 })] }));
43
+ }
44
+ function usePhase(controller) {
45
+ const [phase, setPhase] = useState("parse");
46
+ useEffect(() => controller.subscribe(event => {
47
+ if (event.type === "phase-set")
48
+ setPhase(event.phase);
49
+ }), [controller]);
50
+ return phase;
51
+ }
52
+ function useRuleWidth() {
53
+ const { stdout } = useStdout();
54
+ const [cols, setCols] = useState(() => stdout?.columns ?? 100);
55
+ useEffect(() => {
56
+ if (!stdout)
57
+ return;
58
+ const onResize = () => setCols(stdout.columns ?? 100);
59
+ stdout.on("resize", onResize);
60
+ return () => { stdout.off("resize", onResize); };
61
+ }, [stdout]);
62
+ return Math.min(cols, 120);
63
+ }
64
+ const PHASE_RENDER = {
65
+ pending: (label) => _jsx(Text, { dimColor: true, children: label }),
66
+ running: (label) => _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " ", label] }),
67
+ done: (label) => _jsxs(Text, { color: "green", children: ["\u2713 ", label] }),
68
+ };
69
+ const PHASE_ORDER = ["parse", "resolve", "done"];
70
+ function phaseStateOf(label, current) {
71
+ const li = PHASE_ORDER.indexOf(label);
72
+ const ci = PHASE_ORDER.indexOf(current);
73
+ if (ci > li)
74
+ return "done";
75
+ if (ci === li)
76
+ return "running";
77
+ return "pending";
78
+ }
79
+ function Header({ phase }) {
80
+ 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("resolve", phase)]("resolve")] }));
81
+ }
82
+ function ColumnHeader() {
83
+ 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" }) })] }));
84
+ }
85
+ function Divider({ width }) {
86
+ return _jsx(Text, { dimColor: true, children: "─".repeat(width) });
87
+ }
88
+ const spin = (label) => () => (_jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " ", label] }));
89
+ const STATUS_RENDER = {
90
+ queued: () => _jsx(Text, { color: "gray", children: "queued" }),
91
+ running: spin("running"),
92
+ scanning: spin("scanning"),
93
+ done: () => _jsx(Text, { color: "green", children: "\u2713 done" }),
94
+ failed: () => _jsx(Text, { color: "red", children: "failed" }),
95
+ partial: () => _jsx(Text, { color: "yellow", children: "partial" }),
96
+ };
97
+ const FILE_STATUS_RULES = [
98
+ { when: ({ finished, total }) => finished < total, state: "scanning" },
99
+ { when: ({ failed }) => failed === 0, state: "done" },
100
+ { when: ({ failed, total }) => failed === total, state: "failed" },
101
+ { when: () => true, state: "partial" },
102
+ ];
103
+ function aggregate(chunks, total) {
104
+ let done = 0, failed = 0, totalTx = 0, totalQuestions = 0;
105
+ for (const c of chunks) {
106
+ if (c.status === "done")
107
+ done++;
108
+ else if (c.status === "failed")
109
+ failed++;
110
+ totalTx += c.txCount;
111
+ totalQuestions += c.questionsCount;
112
+ }
113
+ const status = classify({ finished: done + failed, failed, total }, FILE_STATUS_RULES);
114
+ return { totalTx, totalQuestions, status };
115
+ }
116
+ function FileGroupView({ group }) {
117
+ const chunks = Array.from(group.chunks.values()).sort((a, b) => a.pageNumber - b.pageNumber);
118
+ const agg = aggregate(chunks, group.totalChunks);
119
+ const fileName = `> ${truncateMiddle(group.fileName, COL.files - 2)}`;
120
+ 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, i) => (_jsx(ChunkRow, { chunk: c, last: i === chunks.length - 1 }, c.pageNumber)))] }));
121
+ }
122
+ function ChunkRow({ chunk, last }) {
123
+ const connector = last ? "`-" : "|-";
124
+ 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 }));
125
+ }
126
+ function StatusText({ status }) {
127
+ return STATUS_RENDER[status]();
128
+ }
129
+ function Row({ status, files, transactions, questions, }) {
130
+ 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 }) })] }));
131
+ }
132
+ const NUMERIC_RULES = [
133
+ { when: (n) => n > 0, state: "present" },
134
+ { when: () => true, state: "empty" },
135
+ ];
136
+ const NUMERIC_RENDER = {
137
+ present: (n) => _jsx(Text, { children: n }),
138
+ empty: () => (_jsx(Text, { color: "gray", dimColor: true, children: "-" })),
139
+ };
140
+ function Numeric({ n }) {
141
+ return NUMERIC_RENDER[classify(n, NUMERIC_RULES)](n);
142
+ }
143
+ function truncateMiddle(s, width) {
144
+ if (s.length <= width)
145
+ return s;
146
+ const keep = width - 1;
147
+ const left = Math.ceil(keep / 2);
148
+ const right = Math.floor(keep / 2);
149
+ return s.slice(0, left) + "..." + s.slice(s.length - right);
150
+ }
151
+ function useFileGroups(controller, files) {
152
+ const [rows, setRows] = useState(() => seedRows(files));
153
+ useEffect(() => {
154
+ return controller.subscribe((event) => {
155
+ setRows((prev) => {
156
+ const changed = applyDashboardEvent(prev, event);
157
+ return changed ? new Map(prev) : prev;
158
+ });
159
+ });
160
+ }, [controller]);
161
+ return rows;
162
+ }
163
+ function seedRows(files) {
164
+ const seed = new Map();
165
+ for (const f of files) {
166
+ const chunks = new Map();
167
+ for (let p = 1; p <= f.totalPages; p++) {
168
+ chunks.set(p, {
169
+ pageNumber: p,
170
+ status: "queued",
171
+ txCount: 0,
172
+ questionsCount: 0,
173
+ });
174
+ }
175
+ seed.set(f.fileId, {
176
+ fileName: f.fileName,
177
+ totalChunks: f.totalPages,
178
+ chunks,
179
+ });
180
+ }
181
+ return seed;
182
+ }
183
+ const REDUCERS = {
184
+ "chunk-start": (_event, chunk) => {
185
+ if (chunk.status !== "queued")
186
+ return false;
187
+ chunk.status = "running";
188
+ return true;
189
+ },
190
+ "chunk-tx": (_event, chunk) => {
191
+ chunk.txCount++;
192
+ return true;
193
+ },
194
+ "chunk-question": (_event, chunk) => {
195
+ chunk.questionsCount++;
196
+ return true;
197
+ },
198
+ "chunk-end": (event, chunk) => {
199
+ if (TERMINAL_STATUSES.includes(chunk.status))
200
+ return false;
201
+ chunk.status = event.ok ? "done" : "failed";
202
+ return true;
203
+ },
204
+ };
205
+ const TERMINAL_STATUSES = ["done", "failed"];
206
+ function applyDashboardEvent(rows, event) {
207
+ if (event.type === "phase-set")
208
+ return false;
209
+ const chunk = rows.get(event.fileId)?.chunks.get(event.pageNumber);
210
+ if (!chunk)
211
+ return false;
212
+ const reducer = REDUCERS[event.type];
213
+ return reducer(event, chunk);
214
+ }
@@ -1,38 +1,53 @@
1
- export type ScanDashboardEvent = {
2
- type: "scan-start";
1
+ import type { AuditEngine } from "../../scanner/auditor.js";
2
+ import type { Bus } from "../../scanner/bus.js";
3
+ /**
4
+ * Events the CLI publishes into the dashboard to drive chunk-row updates.
5
+ * Audit pulse + chunk tx counters are derived from bus events the dashboard
6
+ * subscribes to directly (no double-routing through the controller).
7
+ */
8
+ export type DashboardEvent = {
9
+ type: "chunk-start";
10
+ fileId: string;
3
11
  fileName: string;
12
+ pageNumber: number;
13
+ totalPages: number;
4
14
  } | {
5
- type: "scan-progress";
6
- fileName: string;
7
- step: string;
15
+ type: "chunk-progress";
16
+ fileId: string;
17
+ pageNumber: number;
18
+ phase: "tool" | "responding";
19
+ toolName?: string;
8
20
  } | {
9
- type: "scan-end";
10
- fileName: string;
11
- status: "scanned" | "failed";
12
- transactions: number;
13
- unknowns: number;
14
- error?: string;
21
+ type: "chunk-tx";
22
+ fileId: string;
23
+ pageNumber: number;
24
+ } | {
25
+ type: "chunk-unknown";
26
+ fileId: string;
27
+ pageNumber: number;
28
+ } | {
29
+ type: "chunk-end";
30
+ fileId: string;
31
+ pageNumber: number;
32
+ ok: boolean;
15
33
  };
16
- /**
17
- * Subscribe / publish channel between the pipeline (which knows nothing about
18
- * UI) and the dashboard (which knows nothing about the pipeline). The CLI
19
- * creates one of these, fans events into it, and hands it to the component.
20
- */
21
- export declare class ScanDashboardController {
22
- private subscribers;
23
- publish(event: ScanDashboardEvent): void;
24
- subscribe(handler: (e: ScanDashboardEvent) => void): () => void;
34
+ export interface ScanDashboardController {
35
+ publish(event: DashboardEvent): void;
36
+ subscribe(handler: (e: DashboardEvent) => void): () => void;
25
37
  }
38
+ export declare function createScanDashboardController(): ScanDashboardController;
26
39
  interface Props {
27
40
  controller: ScanDashboardController;
41
+ bus: Bus;
42
+ audit: AuditEngine;
43
+ scanId: string;
28
44
  totalFiles: number;
29
45
  parallel: number;
30
46
  }
31
47
  /**
32
- * Multi-row live dashboard for the scan phase. Rows appear when a file starts
33
- * scanning, update as steps flow, and freeze when the agent loop ends. Counts
34
- * shown are the in-buffer counts at scan-end; correlation may add unknowns
35
- * later, which the terse summary reflects.
48
+ * Multi-component dashboard: breadcrumb header, audit pulse, file-group panel
49
+ * with per-chunk activity rows, key-bindings footer. Mounted by the CLI on
50
+ * `beforeParse`, unmounted on `afterParse`.
36
51
  */
37
- export declare function ScanDashboard({ controller, totalFiles, parallel }: Props): import("react/jsx-runtime").JSX.Element;
52
+ export declare function ScanDashboard(props: Props): import("react/jsx-runtime").JSX.Element;
38
53
  export {};
@@ -1,62 +1,157 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from "react";
3
3
  import { Box, Text } from "ink";
4
4
  import Spinner from "ink-spinner";
5
- /**
6
- * Subscribe / publish channel between the pipeline (which knows nothing about
7
- * UI) and the dashboard (which knows nothing about the pipeline). The CLI
8
- * creates one of these, fans events into it, and hands it to the component.
9
- */
10
- export class ScanDashboardController {
11
- subscribers = [];
12
- publish(event) {
13
- for (const sub of this.subscribers)
14
- sub(event);
15
- }
16
- subscribe(handler) {
17
- this.subscribers.push(handler);
18
- return () => {
19
- this.subscribers = this.subscribers.filter(s => s !== handler);
20
- };
21
- }
5
+ export function createScanDashboardController() {
6
+ let subscribers = [];
7
+ return {
8
+ publish(event) { for (const sub of subscribers)
9
+ sub(event); },
10
+ subscribe(handler) {
11
+ subscribers.push(handler);
12
+ return () => { subscribers = subscribers.filter(s => s !== handler); };
13
+ },
14
+ };
22
15
  }
23
16
  /**
24
- * Multi-row live dashboard for the scan phase. Rows appear when a file starts
25
- * scanning, update as steps flow, and freeze when the agent loop ends. Counts
26
- * shown are the in-buffer counts at scan-end; correlation may add unknowns
27
- * later, which the terse summary reflects.
17
+ * Multi-component dashboard: breadcrumb header, audit pulse, file-group panel
18
+ * with per-chunk activity rows, key-bindings footer. Mounted by the CLI on
19
+ * `beforeParse`, unmounted on `afterParse`.
28
20
  */
29
- export function ScanDashboard({ controller, totalFiles, parallel }) {
21
+ export function ScanDashboard(props) {
22
+ const rows = useFileGroups(props.controller);
23
+ const audit = useAuditSnapshot(props.bus, props.audit);
24
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { scanId: props.scanId, totalFiles: props.totalFiles, parallel: props.parallel }), _jsx(AuditPulse, { audit: audit }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: Array.from(rows.entries()).map(([fileId, group]) => (_jsx(FileGroupView, { group: group }, fileId))) }), _jsx(Footer, {})] }));
25
+ }
26
+ function Header({ scanId, totalFiles, parallel }) {
27
+ const shortId = scanId.replace(/^sc:/, "").slice(0, 8);
28
+ return (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Plasalid Scan" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 sc:", shortId, " \u00B7 ", totalFiles, " file(s) \u00B7 ", parallel, "\u00D7", parallel, " parallel \u00B7 "] }), _jsx(Text, { color: "green", children: "\u2713 decrypt" }), _jsx(Text, { dimColor: true, children: " \u2192 " }), _jsx(Text, { color: "green", children: "\u2713 chunk" }), _jsx(Text, { dimColor: true, children: " \u2192 " }), _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " parse"] }), _jsx(Text, { dimColor: true, children: " \u2192 \u00B7 review \u2192 \u00B7 commit" })] }));
29
+ }
30
+ function AuditPulse({ audit }) {
31
+ const tallyEntries = Object.entries(audit.tally);
32
+ const tallyStr = tallyEntries.length === 0 ? "" : tallyEntries.map(([k, v]) => `${k}×${v}`).join(" ");
33
+ const idle = audit.pending === 0;
34
+ return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Auditor: " }), idle
35
+ ? _jsx(Text, { color: "green", children: "\u2713 idle" })
36
+ : _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " ", audit.pending, " queued"] }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", audit.processed, " events processed"] }), tallyStr.length > 0 && _jsxs(Text, { dimColor: true, children: [" \u00B7 ", tallyStr] })] }));
37
+ }
38
+ function FileGroupView({ group }) {
39
+ const chunks = Array.from(group.chunks.values()).sort((a, b) => a.pageNumber - b.pageNumber);
40
+ const done = chunks.filter(c => c.status === "done").length;
41
+ const failed = chunks.filter(c => c.status === "failed").length;
42
+ const totalTx = chunks.reduce((s, c) => s + c.txCount, 0);
43
+ const totalUnknowns = chunks.reduce((s, c) => s + c.unknownsCount, 0);
44
+ const finished = done + failed;
45
+ const overall = finished === group.totalChunks
46
+ ? (failed === 0 ? "done" : failed === group.totalChunks ? "failed" : "partial")
47
+ : "scanning";
48
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(FileMarker, { status: overall }), " ", _jsx(Text, { bold: true, children: group.fileName }), _jsxs(Text, { dimColor: true, children: [" ", finished, " / ", group.totalChunks, " chunks \u00B7 ", totalTx, " transactions", totalUnknowns > 0 ? `, ${totalUnknowns} unknowns` : ""] })] }), chunks.map(c => _jsx(ChunkRow, { chunk: c }, c.pageNumber))] }));
49
+ }
50
+ function FileMarker({ status }) {
51
+ if (status === "scanning")
52
+ return _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) });
53
+ if (status === "done")
54
+ return _jsx(Text, { color: "green", children: "\u2713" });
55
+ if (status === "failed")
56
+ return _jsx(Text, { color: "red", children: "\u2717" });
57
+ return _jsx(Text, { color: "yellow", children: "\u26A0" });
58
+ }
59
+ function ChunkRow({ chunk }) {
60
+ return (_jsxs(Text, { children: [" ", _jsx(StatusGlyph, { status: chunk.status }), _jsxs(Text, { dimColor: true, children: [" p", chunk.pageNumber.toString().padEnd(3), " "] }), _jsx(Text, { dimColor: true, children: chunk.activity })] }));
61
+ }
62
+ function StatusGlyph({ status }) {
63
+ if (status === "running")
64
+ return _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) });
65
+ if (status === "done")
66
+ return _jsx(Text, { color: "green", children: "\u2713" });
67
+ if (status === "failed")
68
+ return _jsx(Text, { color: "red", children: "\u2717" });
69
+ return _jsx(Text, { dimColor: true, children: "\u00B7" });
70
+ }
71
+ function Footer() {
72
+ return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191\u2193 scroll \u00B7 q quit \u00B7 ? help" }) }));
73
+ }
74
+ /* ------------------------------------------------------------------------ */
75
+ /* Hooks */
76
+ /* ------------------------------------------------------------------------ */
77
+ function useFileGroups(controller) {
30
78
  const [rows, setRows] = useState(() => new Map());
31
79
  useEffect(() => {
32
80
  return controller.subscribe(event => {
33
81
  setRows(prev => {
34
82
  const next = new Map(prev);
35
- switch (event.type) {
36
- case "scan-start":
37
- next.set(event.fileName, { kind: "scanning", step: "starting..." });
38
- break;
39
- case "scan-progress":
40
- next.set(event.fileName, { kind: "scanning", step: event.step });
41
- break;
42
- case "scan-end":
43
- next.set(event.fileName, event.status === "scanned"
44
- ? { kind: "done", transactions: event.transactions, unknowns: event.unknowns }
45
- : { kind: "failed", error: event.error ?? "failed" });
46
- break;
47
- }
83
+ applyDashboardEvent(next, event);
48
84
  return next;
49
85
  });
50
86
  });
51
87
  }, [controller]);
52
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Scanning ", totalFiles, " file(s) (", parallel, " in parallel)"] }), Array.from(rows.entries()).map(([name, state]) => (_jsx(FileRow, { name: name, state: state }, name)))] }));
88
+ return rows;
53
89
  }
54
- function FileRow({ name, state }) {
55
- if (state.kind === "scanning") {
56
- return (_jsxs(Text, { children: [" ", _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), " ", name, " ", _jsxs(Text, { dimColor: true, children: ["\u00B7 ", state.step] })] }));
57
- }
58
- if (state.kind === "done") {
59
- return (_jsxs(Text, { children: [" ", _jsx(Text, { color: "green", children: "\u2713" }), " ", name, " ", _jsxs(Text, { dimColor: true, children: ["(", state.transactions, " transactions, ", state.unknowns, " unknowns)"] })] }));
90
+ function applyDashboardEvent(rows, event) {
91
+ switch (event.type) {
92
+ case "chunk-start": {
93
+ let group = rows.get(event.fileId);
94
+ if (!group) {
95
+ const chunks = new Map();
96
+ for (let p = 1; p <= event.totalPages; p++) {
97
+ chunks.set(p, { pageNumber: p, status: "queued", activity: "queued", txCount: 0, unknownsCount: 0 });
98
+ }
99
+ group = { fileName: event.fileName, totalChunks: event.totalPages, chunks };
100
+ rows.set(event.fileId, group);
101
+ }
102
+ const chunk = group.chunks.get(event.pageNumber);
103
+ if (chunk) {
104
+ chunk.status = "running";
105
+ chunk.activity = "starting…";
106
+ }
107
+ break;
108
+ }
109
+ case "chunk-progress": {
110
+ const chunk = rows.get(event.fileId)?.chunks.get(event.pageNumber);
111
+ if (chunk && chunk.status === "running") {
112
+ chunk.activity = event.toolName ?? (event.phase === "responding" ? "thinking" : chunk.activity);
113
+ }
114
+ break;
115
+ }
116
+ case "chunk-tx": {
117
+ const chunk = rows.get(event.fileId)?.chunks.get(event.pageNumber);
118
+ if (chunk)
119
+ chunk.txCount++;
120
+ break;
121
+ }
122
+ case "chunk-unknown": {
123
+ const chunk = rows.get(event.fileId)?.chunks.get(event.pageNumber);
124
+ if (chunk)
125
+ chunk.unknownsCount++;
126
+ break;
127
+ }
128
+ case "chunk-end": {
129
+ const chunk = rows.get(event.fileId)?.chunks.get(event.pageNumber);
130
+ if (chunk) {
131
+ chunk.status = event.ok ? "done" : "failed";
132
+ chunk.activity = event.ok
133
+ ? `posted ${chunk.txCount} transactions${chunk.unknownsCount > 0 ? `, ${chunk.unknownsCount} unknowns` : ""}`
134
+ : "failed";
135
+ }
136
+ break;
137
+ }
60
138
  }
61
- return (_jsxs(Text, { children: [" ", _jsx(Text, { color: "red", children: "\u2717" }), " ", name, " ", _jsxs(Text, { dimColor: true, children: ["\u2014 ", state.error] })] }));
139
+ }
140
+ function useAuditSnapshot(bus, audit) {
141
+ const [snap, setSnap] = useState(() => ({
142
+ pending: audit.stats.pending,
143
+ processed: audit.stats.processed,
144
+ tally: { ...audit.tally },
145
+ }));
146
+ useEffect(() => {
147
+ // Re-read on every bus event. Cheap; the pulse is just three numbers.
148
+ return bus.subscribe(() => {
149
+ setSnap({
150
+ pending: audit.stats.pending,
151
+ processed: audit.stats.processed,
152
+ tally: { ...audit.tally },
153
+ });
154
+ });
155
+ }, [bus, audit]);
156
+ return snap;
62
157
  }
@@ -15,7 +15,7 @@ export interface AccountRow {
15
15
  points_balance: number | null;
16
16
  metadata_json: string | null;
17
17
  pii_flag: number;
18
- has_unknown: number;
18
+ has_question: number;
19
19
  created_at: string;
20
20
  }
21
21
  export interface AccountBalance extends AccountRow {
@@ -0,0 +1,62 @@
1
+ import type Database from "libsql";
2
+ export interface QuestionTarget {
3
+ transaction_id: string | null;
4
+ account_id: string | null;
5
+ }
6
+ export interface RecordQuestionInput extends QuestionTarget {
7
+ file_id: string | null;
8
+ scan_id?: string | null;
9
+ kind?: string | null;
10
+ prompt: string;
11
+ options?: string[];
12
+ /** Kind-specific structured context (e.g. partner ids for similar_accounts). */
13
+ context?: Record<string, unknown> | null;
14
+ }
15
+ export interface QuestionRow {
16
+ id: string;
17
+ scan_id: string | null;
18
+ file_id: string | null;
19
+ transaction_id: string | null;
20
+ account_id: string | null;
21
+ kind: string | null;
22
+ prompt: string;
23
+ options_json: string | null;
24
+ context_json: string | null;
25
+ created_at: string;
26
+ }
27
+ export interface ClosedQuestion {
28
+ prompt: string;
29
+ kind: string | null;
30
+ answer: string;
31
+ }
32
+ /**
33
+ * Insert a new questions row and flip the `has_question` boolean on whichever
34
+ * target (transaction / account) was named. Returns the new id. The id keeps
35
+ * the historical `cn:` prefix — it's opaque and nothing else references it,
36
+ * so the prefix is a no-op detail.
37
+ */
38
+ export declare function recordQuestion(db: Database.Database, input: RecordQuestionInput): string;
39
+ /**
40
+ * Close a question by capturing its (prompt, kind, answer) tuple and
41
+ * deleting the row outright. Returns the captured tuple so callers can
42
+ * synthesize memory rules; returns null when the id doesn't exist.
43
+ */
44
+ export declare function closeQuestion(db: Database.Database, id: string, answer: string): ClosedQuestion | null;
45
+ /**
46
+ * Look up the transaction/account a question is attached to. Returns null when
47
+ * the question id doesn't exist.
48
+ */
49
+ export declare function getQuestionTarget(db: Database.Database, id: string): QuestionTarget | null;
50
+ export interface CountQuestionsScope {
51
+ file_id?: string;
52
+ transaction_id?: string;
53
+ account_id?: string;
54
+ kind?: string;
55
+ scan_id?: string;
56
+ }
57
+ export declare function countQuestions(db: Database.Database, scope?: CountQuestionsScope): number;
58
+ export interface ListQuestionsOptions {
59
+ limit?: number;
60
+ scanId?: string;
61
+ }
62
+ export declare function listQuestions(db: Database.Database, opts?: ListQuestionsOptions): QuestionRow[];