plasalid 0.7.9 → 0.8.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.
Files changed (38) hide show
  1. package/README.md +22 -6
  2. package/dist/ai/agent.d.ts +1 -0
  3. package/dist/ai/agent.js +25 -10
  4. package/dist/ai/provider.d.ts +21 -1
  5. package/dist/ai/providers/anthropic.d.ts +0 -1
  6. package/dist/ai/providers/anthropic.js +2 -3
  7. package/dist/ai/providers/gemini.d.ts +14 -0
  8. package/dist/ai/providers/gemini.js +188 -0
  9. package/dist/ai/providers/index.d.ts +2 -1
  10. package/dist/ai/providers/index.js +23 -8
  11. package/dist/ai/providers/openai-compat.d.ts +6 -1
  12. package/dist/ai/providers/openai-compat.js +48 -104
  13. package/dist/ai/providers/openai-shared.d.ts +26 -0
  14. package/dist/ai/providers/openai-shared.js +118 -0
  15. package/dist/ai/providers/openai.d.ts +27 -3
  16. package/dist/ai/providers/openai.js +142 -91
  17. package/dist/cli/commands/scan.js +78 -10
  18. package/dist/cli/commands/status.js +15 -2
  19. package/dist/cli/ink/ScanDashboard.d.ts +7 -6
  20. package/dist/cli/ink/ScanDashboard.js +14 -6
  21. package/dist/cli/setup.js +175 -119
  22. package/dist/config.d.ts +10 -4
  23. package/dist/config.js +40 -11
  24. package/dist/scanner/clarifier.d.ts +2 -0
  25. package/dist/scanner/clarifier.js +1 -0
  26. package/dist/scanner/concurrency.d.ts +9 -2
  27. package/dist/scanner/concurrency.js +3 -1
  28. package/dist/scanner/engine.d.ts +2 -1
  29. package/dist/scanner/engine.js +21 -3
  30. package/dist/scanner/hooks.d.ts +6 -0
  31. package/dist/scanner/parse.js +28 -16
  32. package/dist/scanner/pdf/pdf.d.ts +3 -2
  33. package/dist/scanner/pdf/pdf.js +11 -1
  34. package/dist/scanner/pdf/rasterize.d.ts +6 -0
  35. package/dist/scanner/pdf/rasterize.js +36 -0
  36. package/dist/scanner/worker.d.ts +6 -0
  37. package/dist/scanner/worker.js +16 -3
  38. package/package.json +2 -1
@@ -0,0 +1,26 @@
1
+ import OpenAI from "openai";
2
+ import type { NormalizedResponse, NormalizedContentBlock, NormalizedMessage, NormalizedToolResult, ToolDefinition } from "../provider.js";
3
+ export type ChatCompletion = OpenAI.Chat.Completions.ChatCompletion;
4
+ type RequestOptions = Parameters<OpenAI["chat"]["completions"]["create"]>[1];
5
+ export interface CompletionBody {
6
+ model: string;
7
+ maxTokens: number;
8
+ messages: OpenAI.ChatCompletionMessageParam[];
9
+ tools: OpenAI.ChatCompletionTool[] | undefined;
10
+ }
11
+ /**
12
+ * Older models accept `max_tokens`; o-series / gpt-5+ require
13
+ * `max_completion_tokens`. Try the former, fall back on the 400.
14
+ */
15
+ export declare function createCompletionWithTokenFallback(client: OpenAI, body: CompletionBody, options: RequestOptions): Promise<ChatCompletion>;
16
+ export declare function normalizeResponse(response: ChatCompletion): NormalizedResponse;
17
+ export declare function convertTools(tools: ToolDefinition[]): OpenAI.ChatCompletionTool[];
18
+ /**
19
+ * Throw on malformed JSON so truncated tool args surface as an error instead
20
+ * of being silently coerced to `{}` (which would record zero transactions).
21
+ */
22
+ export declare function parseArguments(toolName: string, args: string): unknown;
23
+ export declare function convertToolResults(toolResults: NormalizedToolResult[]): OpenAI.ChatCompletionToolMessageParam[];
24
+ export declare function convertAssistantMessage(blocks: NormalizedContentBlock[]): OpenAI.ChatCompletionAssistantMessageParam;
25
+ export declare function isToolResultEnvelope(content: NormalizedMessage["content"]): content is NormalizedToolResult[];
26
+ export {};
@@ -0,0 +1,118 @@
1
+ function isMaxTokensRejection(e) {
2
+ const err = e;
3
+ return err.status === 400 && (err.message?.includes("max_tokens") ?? false);
4
+ }
5
+ /**
6
+ * Older models accept `max_tokens`; o-series / gpt-5+ require
7
+ * `max_completion_tokens`. Try the former, fall back on the 400.
8
+ */
9
+ export async function createCompletionWithTokenFallback(client, body, options) {
10
+ const base = {
11
+ model: body.model,
12
+ messages: body.messages,
13
+ tools: body.tools,
14
+ };
15
+ try {
16
+ return await client.chat.completions.create({ ...base, max_tokens: body.maxTokens }, options);
17
+ }
18
+ catch (e) {
19
+ if (isMaxTokensRejection(e)) {
20
+ return await client.chat.completions.create({ ...base, max_completion_tokens: body.maxTokens }, options);
21
+ }
22
+ throw e;
23
+ }
24
+ }
25
+ export function normalizeResponse(response) {
26
+ const choice = response.choices[0];
27
+ if (!choice) {
28
+ return { content: [], stopReason: "end_turn" };
29
+ }
30
+ const content = [];
31
+ if (choice.message.content) {
32
+ content.push({ type: "text", text: choice.message.content });
33
+ }
34
+ if (choice.message.tool_calls) {
35
+ for (const tc of choice.message.tool_calls) {
36
+ if (tc.type !== "function")
37
+ continue;
38
+ content.push({
39
+ type: "tool_use",
40
+ id: tc.id,
41
+ name: tc.function.name,
42
+ input: parseArguments(tc.function.name, tc.function.arguments),
43
+ });
44
+ }
45
+ }
46
+ const hasToolCalls = content.some((b) => b.type === "tool_use");
47
+ /**
48
+ * finish_reason "length" → "max_tokens" so the agent loop records a
49
+ * scan_truncated question instead of silently accepting a partial batch.
50
+ */
51
+ const stopReason = choice.finish_reason === "length"
52
+ ? "max_tokens"
53
+ : hasToolCalls
54
+ ? "tool_use"
55
+ : "end_turn";
56
+ return {
57
+ content,
58
+ stopReason,
59
+ usage: response.usage
60
+ ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens }
61
+ : undefined,
62
+ };
63
+ }
64
+ export function convertTools(tools) {
65
+ return tools.map((t) => ({
66
+ type: "function",
67
+ function: {
68
+ name: t.name,
69
+ description: t.description,
70
+ parameters: t.input_schema,
71
+ },
72
+ }));
73
+ }
74
+ /**
75
+ * Throw on malformed JSON so truncated tool args surface as an error instead
76
+ * of being silently coerced to `{}` (which would record zero transactions).
77
+ */
78
+ export function parseArguments(toolName, args) {
79
+ if (typeof args !== "string")
80
+ return args;
81
+ try {
82
+ return JSON.parse(args);
83
+ }
84
+ catch {
85
+ const preview = args.length > 120 ? `${args.slice(0, 120)}…` : args;
86
+ throw new Error(`Tool call arguments for "${toolName}" were not valid JSON (likely truncated by max_tokens): ${preview}`);
87
+ }
88
+ }
89
+ export function convertToolResults(toolResults) {
90
+ return toolResults.map((tr) => ({
91
+ role: "tool",
92
+ tool_call_id: tr.tool_use_id,
93
+ content: tr.content,
94
+ }));
95
+ }
96
+ export function convertAssistantMessage(blocks) {
97
+ const textParts = blocks
98
+ .filter((b) => b.type === "text")
99
+ .map((b) => b.text)
100
+ .join("\n");
101
+ const toolCalls = blocks
102
+ .filter((b) => b.type === "tool_use")
103
+ .map((tu) => ({
104
+ id: tu.id,
105
+ type: "function",
106
+ function: { name: tu.name, arguments: JSON.stringify(tu.input) },
107
+ }));
108
+ return {
109
+ role: "assistant",
110
+ content: textParts || null,
111
+ ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
112
+ };
113
+ }
114
+ export function isToolResultEnvelope(content) {
115
+ return (Array.isArray(content) &&
116
+ content.length > 0 &&
117
+ content[0].type === "tool_result");
118
+ }
@@ -1,5 +1,29 @@
1
- import type { Provider } from "../provider.js";
2
- export declare function createOpenAICompatibleProvider(opts: {
1
+ import OpenAI from "openai";
2
+ import type { Provider, NormalizedResponse, NormalizedContentBlock, NormalizedMessage, NormalizedToolResult, ToolDefinition } from "../provider.js";
3
+ export type ChatCompletion = OpenAI.Chat.Completions.ChatCompletion;
4
+ type RequestOptions = Parameters<OpenAI["chat"]["completions"]["create"]>[1];
5
+ export interface CompletionBody {
6
+ model: string;
7
+ maxTokens: number;
8
+ messages: OpenAI.ChatCompletionMessageParam[];
9
+ tools: OpenAI.ChatCompletionTool[] | undefined;
10
+ }
11
+ export declare function createOpenAIProvider(opts: {
3
12
  apiKey: string;
4
- baseURL: string;
5
13
  }): Provider;
14
+ /**
15
+ * Older models accept `max_tokens`; o-series / gpt-5+ require
16
+ * `max_completion_tokens`. Try the former, fall back on the 400.
17
+ */
18
+ export declare function createCompletionWithTokenFallback(client: OpenAI, body: CompletionBody, options: RequestOptions): Promise<ChatCompletion>;
19
+ export declare function normalizeResponse(response: ChatCompletion): NormalizedResponse;
20
+ export declare function convertTools(tools: ToolDefinition[]): OpenAI.ChatCompletionTool[];
21
+ /**
22
+ * Throw on malformed JSON so truncated tool args surface as an error instead
23
+ * of being silently coerced to `{}` (which would record zero transactions).
24
+ */
25
+ export declare function parseArguments(toolName: string, args: string): unknown;
26
+ export declare function convertToolResults(toolResults: NormalizedToolResult[]): OpenAI.ChatCompletionToolMessageParam[];
27
+ export declare function convertAssistantMessage(blocks: NormalizedContentBlock[]): OpenAI.ChatCompletionAssistantMessageParam;
28
+ export declare function isToolResultEnvelope(content: NormalizedMessage["content"]): content is NormalizedToolResult[];
29
+ export {};
@@ -1,38 +1,15 @@
1
1
  import OpenAI from "openai";
2
2
  import { classifyProviderError } from "../errors.js";
3
- function isMaxTokensRejection(e) {
4
- const err = e;
5
- return err.status === 400 && (err.message?.includes("max_tokens") ?? false);
6
- }
7
- /**
8
- * Some OpenAI-compatible endpoints (older models, Ollama, vLLM) accept `max_tokens`;
9
- * newer OpenAI models require `max_completion_tokens`. Try the former, fall back on a
10
- * 400 that explicitly names the parameter.
11
- */
12
- async function createCompletionWithTokenFallback(client, body, options) {
13
- const base = {
14
- model: body.model,
15
- messages: body.messages,
16
- tools: body.tools,
17
- };
18
- try {
19
- return await client.chat.completions.create({ ...base, max_tokens: body.maxTokens }, options);
20
- }
21
- catch (e) {
22
- if (isMaxTokensRejection(e)) {
23
- return await client.chat.completions.create({ ...base, max_completion_tokens: body.maxTokens }, options);
24
- }
25
- throw e;
26
- }
27
- }
28
- export function createOpenAICompatibleProvider(opts) {
3
+ const OPENAI_BASE_URL = "https://api.openai.com/v1";
4
+ export function createOpenAIProvider(opts) {
29
5
  const client = new OpenAI({
30
6
  apiKey: opts.apiKey,
31
- baseURL: opts.baseURL,
7
+ baseURL: OPENAI_BASE_URL,
32
8
  });
33
9
  return {
34
- name: "openai-compatible",
10
+ name: "openai",
35
11
  supportsThinking: false,
12
+ acceptsDocuments: true,
36
13
  async sendMessage(params) {
37
14
  const tools = convertTools(params.tools);
38
15
  const body = {
@@ -52,7 +29,94 @@ export function createOpenAICompatibleProvider(opts) {
52
29
  },
53
30
  };
54
31
  }
55
- function normalizeResponse(response) {
32
+ function convertMessages(system, messages) {
33
+ const result = [
34
+ { role: "system", content: system },
35
+ ];
36
+ for (const msg of messages) {
37
+ if (msg.role === "user") {
38
+ if (isToolResultEnvelope(msg.content)) {
39
+ result.push(...convertToolResults(msg.content));
40
+ }
41
+ else if (Array.isArray(msg.content)) {
42
+ result.push(buildUserMessage(msg.content));
43
+ }
44
+ else {
45
+ result.push({ role: "user", content: msg.content });
46
+ }
47
+ }
48
+ else {
49
+ if (Array.isArray(msg.content)) {
50
+ result.push(convertAssistantMessage(msg.content));
51
+ }
52
+ else {
53
+ result.push({ role: "assistant", content: msg.content });
54
+ }
55
+ }
56
+ }
57
+ return result;
58
+ }
59
+ /** Real OpenAI accepts `file` parts for PDFs and `image_url` for images. */
60
+ function buildUserMessage(blocks) {
61
+ const hasAttachment = blocks.some((b) => b.type === "document" || b.type === "image");
62
+ if (!hasAttachment) {
63
+ const text = blocks
64
+ .filter((b) => b.type === "text")
65
+ .map((b) => b.text)
66
+ .join("\n");
67
+ return { role: "user", content: text };
68
+ }
69
+ const parts = [];
70
+ for (const block of blocks) {
71
+ if (block.type === "text") {
72
+ parts.push({ type: "text", text: block.text });
73
+ }
74
+ else if (block.type === "document") {
75
+ parts.push({
76
+ type: "file",
77
+ file: {
78
+ filename: block.title ?? "document.pdf",
79
+ file_data: `data:${block.source.media_type};base64,${block.source.data}`,
80
+ },
81
+ });
82
+ }
83
+ else if (block.type === "image") {
84
+ parts.push({
85
+ type: "image_url",
86
+ image_url: { url: `data:${block.source.media_type};base64,${block.source.data}` },
87
+ });
88
+ }
89
+ }
90
+ return { role: "user", content: parts };
91
+ }
92
+ /* ------------------------------------------------------------------ */
93
+ /* Shared helpers — also imported by openai-compat.ts. */
94
+ /* ------------------------------------------------------------------ */
95
+ function isMaxTokensRejection(e) {
96
+ const err = e;
97
+ return err.status === 400 && (err.message?.includes("max_tokens") ?? false);
98
+ }
99
+ /**
100
+ * Older models accept `max_tokens`; o-series / gpt-5+ require
101
+ * `max_completion_tokens`. Try the former, fall back on the 400.
102
+ */
103
+ export async function createCompletionWithTokenFallback(client, body, options) {
104
+ const base = {
105
+ model: body.model,
106
+ messages: body.messages,
107
+ tools: body.tools,
108
+ };
109
+ try {
110
+ return await client.chat.completions.create({ ...base, max_tokens: body.maxTokens }, options);
111
+ }
112
+ catch (e) {
113
+ if (isMaxTokensRejection(e)) {
114
+ return await client.chat.completions.create({ ...base, max_completion_tokens: body.maxTokens }, options);
115
+ }
116
+ throw e;
117
+ }
118
+ }
119
+ export function normalizeResponse(response) {
56
120
  const choice = response.choices[0];
57
121
  if (!choice) {
58
122
  return { content: [], stopReason: "end_turn" };
@@ -69,77 +133,29 @@ function normalizeResponse(response) {
69
133
  type: "tool_use",
70
134
  id: tc.id,
71
135
  name: tc.function.name,
72
- input: parseArguments(tc.function.arguments),
136
+ input: parseArguments(tc.function.name, tc.function.arguments),
73
137
  });
74
138
  }
75
139
  }
76
140
  const hasToolCalls = content.some((b) => b.type === "tool_use");
141
+ /**
142
+ * finish_reason "length" → "max_tokens" so the agent loop records a
143
+ * scan_truncated question instead of silently accepting a partial batch.
144
+ */
145
+ const stopReason = choice.finish_reason === "length"
146
+ ? "max_tokens"
147
+ : hasToolCalls
148
+ ? "tool_use"
149
+ : "end_turn";
77
150
  return {
78
151
  content,
79
- stopReason: hasToolCalls ? "tool_use" : "end_turn",
152
+ stopReason,
80
153
  usage: response.usage
81
154
  ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens }
82
155
  : undefined,
83
156
  };
84
157
  }
85
- function convertMessages(system, messages) {
86
- const result = [
87
- { role: "system", content: system },
88
- ];
89
- for (const msg of messages) {
90
- if (msg.role === "user") {
91
- if (Array.isArray(msg.content) &&
92
- msg.content.length > 0 &&
93
- msg.content[0].type === "tool_result") {
94
- const toolResults = msg.content;
95
- for (const tr of toolResults) {
96
- result.push({
97
- role: "tool",
98
- tool_call_id: tr.tool_use_id,
99
- content: tr.content,
100
- });
101
- }
102
- }
103
- else if (Array.isArray(msg.content)) {
104
- // Strip document blocks (OpenAI-compat doesn't accept them); keep text.
105
- const text = msg.content
106
- .filter((b) => b.type === "text")
107
- .map((b) => b.text)
108
- .join("\n");
109
- result.push({ role: "user", content: text });
110
- }
111
- else {
112
- result.push({ role: "user", content: msg.content });
113
- }
114
- }
115
- else {
116
- if (Array.isArray(msg.content)) {
117
- const blocks = msg.content;
118
- const textParts = blocks
119
- .filter((b) => b.type === "text")
120
- .map((b) => b.text)
121
- .join("\n");
122
- const toolCalls = blocks
123
- .filter((b) => b.type === "tool_use")
124
- .map((tu) => ({
125
- id: tu.id,
126
- type: "function",
127
- function: { name: tu.name, arguments: JSON.stringify(tu.input) },
128
- }));
129
- result.push({
130
- role: "assistant",
131
- content: textParts || null,
132
- ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
133
- });
134
- }
135
- else {
136
- result.push({ role: "assistant", content: msg.content });
137
- }
138
- }
139
- }
140
- return result;
141
- }
142
- function convertTools(tools) {
158
+ export function convertTools(tools) {
143
159
  return tools.map((t) => ({
144
160
  type: "function",
145
161
  function: {
@@ -149,13 +165,48 @@ function convertTools(tools) {
149
165
  },
150
166
  }));
151
167
  }
152
- function parseArguments(args) {
168
+ /**
169
+ * Throw on malformed JSON so truncated tool args surface as an error instead
170
+ * of being silently coerced to `{}` (which would record zero transactions).
171
+ */
172
+ export function parseArguments(toolName, args) {
153
173
  if (typeof args !== "string")
154
174
  return args;
155
175
  try {
156
176
  return JSON.parse(args);
157
177
  }
158
178
  catch {
159
- return {};
179
+ const preview = args.length > 120 ? `${args.slice(0, 120)}…` : args;
180
+ throw new Error(`Tool call arguments for "${toolName}" were not valid JSON (likely truncated by max_tokens): ${preview}`);
160
181
  }
161
182
  }
183
+ export function convertToolResults(toolResults) {
184
+ return toolResults.map((tr) => ({
185
+ role: "tool",
186
+ tool_call_id: tr.tool_use_id,
187
+ content: tr.content,
188
+ }));
189
+ }
190
+ export function convertAssistantMessage(blocks) {
191
+ const textParts = blocks
192
+ .filter((b) => b.type === "text")
193
+ .map((b) => b.text)
194
+ .join("\n");
195
+ const toolCalls = blocks
196
+ .filter((b) => b.type === "tool_use")
197
+ .map((tu) => ({
198
+ id: tu.id,
199
+ type: "function",
200
+ function: { name: tu.name, arguments: JSON.stringify(tu.input) },
201
+ }));
202
+ return {
203
+ role: "assistant",
204
+ content: textParts || null,
205
+ ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
206
+ };
207
+ }
208
+ export function isToolResultEnvelope(content) {
209
+ return (Array.isArray(content) &&
210
+ content.length > 0 &&
211
+ content[0].type === "tool_result");
212
+ }
@@ -1,6 +1,14 @@
1
1
  import chalk from "chalk";
2
2
  import { getDb } from "../../db/connection.js";
3
3
  import { runScan } from "../../scanner/engine.js";
4
+ import { getActiveModel } from "../../config.js";
5
+ import { getProvider } from "../../ai/providers/index.js";
6
+ import { AbortedError } from "../../ai/errors.js";
7
+ /** Show the cursor — always safe; mirrors the TTY mount-time hide. */
8
+ function restoreTerminal() {
9
+ if (process.stdout.isTTY)
10
+ process.stdout.write("\x1b[?25h");
11
+ }
4
12
  export async function runScanCommand(opts) {
5
13
  if (opts.regex) {
6
14
  try {
@@ -14,17 +22,46 @@ export async function runScanCommand(opts) {
14
22
  }
15
23
  const parallel = opts.parallel ?? 5;
16
24
  const isTTY = !!process.stdout.isTTY;
17
- const hooks = isTTY ? await buildTtyHooks() : buildPlainHooks();
18
- const result = await runScan(getDb(), {
19
- regex: opts.regex,
20
- force: opts.force,
21
- interactive: true,
22
- maxFileWorkers: parallel,
23
- }, hooks);
24
- renderSummary(result.state);
25
+ const controller = new AbortController();
26
+ let sigintCount = 0;
27
+ const onSigint = () => {
28
+ sigintCount++;
29
+ if (sigintCount === 1) {
30
+ controller.abort();
31
+ return;
32
+ }
33
+ restoreTerminal();
34
+ process.exit(130);
35
+ };
36
+ process.on("SIGINT", onSigint);
37
+ const hooks = isTTY
38
+ ? await buildTtyHooks(controller.signal)
39
+ : buildPlainHooks(controller.signal);
40
+ try {
41
+ const result = await runScan(getDb(), {
42
+ regex: opts.regex,
43
+ force: opts.force,
44
+ interactive: true,
45
+ maxFileWorkers: parallel,
46
+ }, hooks, controller.signal);
47
+ renderSummary(result.state);
48
+ }
49
+ catch (err) {
50
+ if (err instanceof AbortedError) {
51
+ restoreTerminal();
52
+ console.log("");
53
+ console.log(chalk.yellow("scan cancelled. anything committed before cancel stays in the database (run `scan --force` or `revert`)."));
54
+ process.exitCode = 130;
55
+ return;
56
+ }
57
+ throw err;
58
+ }
59
+ finally {
60
+ process.removeListener("SIGINT", onSigint);
61
+ }
25
62
  }
26
63
  /* TTY mode — Ink dashboard with one in-place row per file. */
27
- async function buildTtyHooks() {
64
+ async function buildTtyHooks(signal) {
28
65
  const { render } = await import("ink");
29
66
  const { createElement } = await import("react");
30
67
  const { ScanDashboard, createScanDashboardController } = await import("../ink/ScanDashboard.js");
@@ -32,6 +69,14 @@ async function buildTtyHooks() {
32
69
  let inkInstance = null;
33
70
  let unsubscribeProgress = null;
34
71
  const chunkLookup = new Map();
72
+ // Surface cancellation through Ink's controller, not raw stdout — writing
73
+ // to stdout while Ink is rendering corrupts its frame tracking and leaves
74
+ // a phantom copy of the header in scrollback. once:true so the listener
75
+ // self-removes without leaking past the scan run.
76
+ const onAbortEvt = () => {
77
+ controller.publish({ type: "phase-set", phase: "cancelling" });
78
+ };
79
+ signal.addEventListener("abort", onAbortEvt, { once: true });
35
80
  return {
36
81
  afterDecrypt: (s) => {
37
82
  const total = s.decrypted.length + s.skipped.length + s.failed.length;
@@ -60,9 +105,16 @@ async function buildTtyHooks() {
60
105
  fileName: d.fileName,
61
106
  totalPages: s.chunks.filter((c) => c.fileId === d.path).length,
62
107
  }));
108
+ const provider = getProvider();
109
+ const attachment = {
110
+ format: provider.acceptsDocuments ? "pdf" : "png",
111
+ providerName: provider.name,
112
+ modelName: getActiveModel(),
113
+ };
63
114
  inkInstance = render(createElement(ScanDashboard, {
64
115
  controller,
65
116
  files,
117
+ attachment,
66
118
  }), {
67
119
  exitOnCtrlC: false,
68
120
  patchConsole: false,
@@ -108,6 +160,18 @@ async function buildTtyHooks() {
108
160
  inkInstance = null;
109
161
  process.stdout.write("\x1b[?25h");
110
162
  },
163
+ /**
164
+ * Cancellation lands here when the user hits Ctrl+C mid-scan. Drop the
165
+ * Ink dashboard immediately so subsequent stderr lines aren't trapped
166
+ * under its render, and restore the cursor we hid at mount time.
167
+ */
168
+ onAbort: () => {
169
+ unsubscribeProgress?.();
170
+ unsubscribeProgress = null;
171
+ inkInstance?.unmount();
172
+ inkInstance = null;
173
+ process.stdout.write("\x1b[?25h");
174
+ },
111
175
  };
112
176
  }
113
177
  const FINALIZE_RULES = [
@@ -126,10 +190,14 @@ function classifyFinalize(t) {
126
190
  return r.kind;
127
191
  return "partial";
128
192
  }
129
- function buildPlainHooks() {
193
+ function buildPlainHooks(signal) {
130
194
  const tallies = new Map();
131
195
  const fileIdByChunkId = new Map();
132
196
  let unsubscribeProgress = null;
197
+ // No Ink in this mode, so writing one dim line directly is safe.
198
+ signal.addEventListener("abort", () => {
199
+ console.log(chalk.dim("Cancelling… waiting for in-flight work."));
200
+ }, { once: true });
133
201
  const finalize = (fileId) => {
134
202
  const t = tallies.get(fileId);
135
203
  if (!t || t.completedChunks + t.failedChunks < t.totalChunks)
@@ -6,6 +6,7 @@ import { getRecurringSummary } from "../../db/queries/recurrences.js";
6
6
  import { countScannedFiles } from "../../db/queries/files.js";
7
7
  import { countQuestions } from "../../db/queries/questions.js";
8
8
  import { countMemories } from "../../ai/memory.js";
9
+ import { config, getActiveModel } from "../../config.js";
9
10
  import { formatAmount } from "../../currency.js";
10
11
  import { visibleLength } from "../format.js";
11
12
  const LABEL_WIDTH = 18;
@@ -14,6 +15,8 @@ export function showStatus() {
14
15
  printSection("Financial", financialRows(db));
15
16
  console.log("");
16
17
  printSection("System", systemRows(db));
18
+ console.log("");
19
+ printSection("Model", modelRows(), { align: "left" });
17
20
  }
18
21
  function financialRows(db) {
19
22
  const nw = getNetWorth(db);
@@ -72,7 +75,14 @@ function systemRows(db) {
72
75
  }
73
76
  return rows;
74
77
  }
75
- function printSection(title, rows) {
78
+ function modelRows() {
79
+ return [
80
+ { label: "Provider", value: config.providerType },
81
+ { label: "Model", value: getActiveModel() },
82
+ ];
83
+ }
84
+ function printSection(title, rows, opts) {
85
+ const align = opts?.align ?? "right";
76
86
  console.log(chalk.bold(title));
77
87
  console.log(chalk.dim("─".repeat(title.length)));
78
88
  const valueWidth = Math.max(0, ...rows.map((r) => visibleLength(r.value)));
@@ -80,7 +90,10 @@ function printSection(title, rows) {
80
90
  const label = row.label.padEnd(LABEL_WIDTH);
81
91
  const valuePad = " ".repeat(Math.max(0, valueWidth - visibleLength(row.value)));
82
92
  const suffix = row.suffix ? ` ${row.suffix}` : "";
83
- console.log(` ${label}${valuePad}${row.value}${suffix}`);
93
+ const body = align === "left"
94
+ ? `${row.value}${valuePad}${suffix}`
95
+ : `${valuePad}${row.value}${suffix}`;
96
+ console.log(` ${label}${body}`);
84
97
  }
85
98
  }
86
99
  function formatInteger(n) {
@@ -2,7 +2,7 @@
2
2
  * Events the CLI publishes into the dashboard. The CLI subscribes to the
3
3
  * scanner's ScanProgress sink and routes per-chunk ticks here via chunkLookup.
4
4
  */
5
- export type CurrentPhase = "parse" | "clarify" | "done";
5
+ export type CurrentPhase = "parse" | "clarify" | "cancelling" | "done";
6
6
  export type DashboardEvent = {
7
7
  type: "chunk-start";
8
8
  fileId: string;
@@ -36,14 +36,15 @@ export interface FileSeed {
36
36
  readonly fileName: string;
37
37
  readonly totalPages: number;
38
38
  }
39
+ export interface AttachmentInfo {
40
+ format: "pdf" | "png";
41
+ providerName: string;
42
+ modelName: string;
43
+ }
39
44
  interface Props {
40
45
  controller: ScanDashboardController;
41
46
  files: ReadonlyArray<FileSeed>;
47
+ attachment: AttachmentInfo;
42
48
  }
43
- /**
44
- * Tree-layout scan dashboard. Header carries the only animated element (one
45
- * `<Spinner>`). All other status indicators are static glyphs that only
46
- * redraw when their data changes.
47
- */
48
49
  export declare function ScanDashboard(props: Props): import("react/jsx-runtime").JSX.Element;
49
50
  export {};