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
@@ -30,16 +30,18 @@ const COL = {
30
30
  transactions: 13,
31
31
  questions: 10,
32
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
33
  export function ScanDashboard(props) {
39
34
  const rows = useFileGroups(props.controller, props.files);
40
35
  const phase = usePhase(props.controller);
41
36
  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 })] }));
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, {})] }));
38
+ }
39
+ function Footnote() {
40
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "output accuracy depends on the model's VL capability." }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "we also provide " }), _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 rectify the data later." })] })] }));
41
+ }
42
+ function AttachmentLine({ info }) {
43
+ const detail = info.format === "pdf" ? "pdf (native)" : "png (rasterized)";
44
+ return (_jsxs(Text, { dimColor: true, children: ["sending: ", detail, " (", info.providerName, "/", info.modelName, ")"] }));
43
45
  }
44
46
  function usePhase(controller) {
45
47
  const [phase, setPhase] = useState("parse");
@@ -79,6 +81,12 @@ function phaseStateOf(label, current) {
79
81
  return "pending";
80
82
  }
81
83
  function Header({ phase }) {
84
+ // Cancellation collapses the parse/clarify segments — neither is still
85
+ // running once the user hits Ctrl+C, and showing them as "pending" would
86
+ // be misleading. The single "cancelling…" label communicates the wind-down.
87
+ if (phase === "cancelling") {
88
+ return (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Scanner" }), _jsx(Text, { dimColor: true, children: " · " }), _jsx(Text, { color: "green", children: "\u2713 decrypt" }), _jsx(Text, { dimColor: true, children: " -> " }), _jsx(Text, { color: "green", children: "\u2713 chunk" }), _jsx(Text, { dimColor: true, children: " -> " }), _jsxs(Text, { color: "red", children: [_jsx(Spinner, { type: "dots" }), " cancelling\u2026"] })] }));
89
+ }
82
90
  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")] }));
83
91
  }
84
92
  function ColumnHeader() {
package/dist/cli/setup.js CHANGED
@@ -6,26 +6,24 @@ import { config, saveConfig, getConfigPath, getPlasalidDir, getDataDir, } from "
6
6
  import { generateKey } from "../db/encryption.js";
7
7
  import { createContextTemplate } from "../ai/context.js";
8
8
  import { printLogo } from "./logo.js";
9
- const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6";
10
- const DEFAULT_OPENAI_BASE_URL = "http://localhost:11434/v1";
9
+ import { statusSpinner } from "./ux.js";
10
+ const DEFAULT_LOCAL_OPENAI_BASE_URL = "http://localhost:11434/v1";
11
+ const RECOMMENDED_MODEL = {
12
+ anthropic: "claude-sonnet-4-6",
13
+ openai: "gpt-5.4-mini",
14
+ gemini: "gemini-2.5-pro",
15
+ };
11
16
  function ensureDir(p) {
12
17
  if (!existsSync(p))
13
18
  mkdirSync(p, { recursive: true });
14
19
  }
15
- function readEnvDefaults() {
16
- return {
17
- anthropicKey: process.env.ANTHROPIC_API_KEY?.trim() || "",
18
- userName: process.env.PLASALID_USER_NAME?.trim() || "",
19
- openaiBaseURL: process.env.OPENAI_COMPATIBLE_BASE_URL?.trim() || "",
20
- openaiKey: process.env.OPENAI_COMPATIBLE_API_KEY?.trim() || "",
21
- };
22
- }
23
20
  function printBanner() {
24
21
  console.log("");
25
22
  printLogo();
26
23
  console.log("");
27
24
  console.log("Welcome to Plasalid. Let's get you set up — a few quick questions.");
28
25
  console.log("");
26
+ console.log(chalk.dim("Time to power up your engine — wire in an AI, pick a model, seal your vault."));
29
27
  }
30
28
  function printSummary(dataDir) {
31
29
  console.log("");
@@ -42,9 +40,10 @@ function printSummary(dataDir) {
42
40
  console.log(chalk.dim(` Optional: ${chalk.cyan(`plasalid record "..."`)}${chalk.dim(" to record manual/undocumented transaction, balance, or account at any time.")}`));
43
41
  }
44
42
  /**
45
- * Wraps inquirer's list prompt with a blank line above and below, and inserts
46
- * a Separator(" ") row above the first choice so the question and the first
47
- * option don't crowd each other. Mirrors `makePromptUser` in `src/cli/ux.ts`.
43
+ * Each helper prints one leading blank line. Inquirer collapses the resolved
44
+ * prompt to a single line, so each new helper produces exactly one blank row
45
+ * between adjacent questions. passwordPrompt has no `default` because the
46
+ * masked-but-pre-filled state confuses "press Enter to keep".
48
47
  */
49
48
  async function listPrompt(opts) {
50
49
  console.log("");
@@ -53,134 +52,193 @@ async function listPrompt(opts) {
53
52
  type: "list",
54
53
  name: opts.name,
55
54
  message: opts.message,
56
- choices: [new inquirer.Separator(" "), ...opts.choices],
55
+ choices: opts.choices,
57
56
  default: opts.default,
58
57
  },
59
58
  ]);
60
- console.log("");
61
59
  return answer[opts.name];
62
60
  }
63
- async function promptUserName(env) {
64
- const { userName } = await inquirer.prompt([
61
+ async function inputPrompt(opts) {
62
+ console.log("");
63
+ const answer = await inquirer.prompt([
65
64
  {
66
65
  type: "input",
67
- name: "userName",
68
- message: "What should I call you? (Your name)",
69
- default: env.userName || (config.userName === "User" ? "" : config.userName),
66
+ name: opts.name,
67
+ message: opts.message,
68
+ default: opts.default,
69
+ validate: opts.validate,
70
+ },
71
+ ]);
72
+ return String(answer[opts.name] ?? "").trim();
73
+ }
74
+ async function passwordPrompt(opts) {
75
+ console.log("");
76
+ const answer = await inquirer.prompt([
77
+ {
78
+ type: "password",
79
+ name: opts.name,
80
+ message: opts.message,
81
+ mask: "*",
82
+ validate: opts.validate,
70
83
  },
71
84
  ]);
72
- return String(userName || "").trim();
85
+ return String(answer[opts.name] ?? "");
86
+ }
87
+ function savedModelFor(vendor) {
88
+ switch (vendor) {
89
+ case "anthropic":
90
+ return config.anthropicModel;
91
+ case "openai":
92
+ return config.openaiModel;
93
+ case "gemini":
94
+ return config.geminiModel;
95
+ case "openai-compat":
96
+ return config.openaiCompatModel;
97
+ }
98
+ }
99
+ async function promptUserName() {
100
+ return inputPrompt({
101
+ name: "userName",
102
+ message: "What should I call you? (Your name)",
103
+ });
73
104
  }
74
105
  async function promptProviderChoice() {
75
106
  return listPrompt({
76
- name: "providerChoice",
107
+ name: "vendor",
77
108
  message: "Which AI provider would you like to use?",
78
109
  choices: [
79
- { name: "Anthropic (Claude)", value: "anthropic" },
110
+ { name: "Anthropic", value: "anthropic" },
111
+ { name: "OpenAI", value: "openai" },
112
+ { name: "Google Gemini", value: "gemini" },
80
113
  {
81
- name: "OpenAI compatible (OpenAI, LM Studio, vLLM, Ollama)",
82
- value: "openai-compatible",
114
+ name: "OpenAI Compatible (LM Studio, vLLM, Ollama, other)",
115
+ value: "openai-compat",
83
116
  },
84
117
  ],
85
118
  default: "anthropic",
86
119
  });
87
120
  }
88
- async function promptAnthropicCredentials(env) {
89
- const { key, model } = await inquirer.prompt([
90
- {
91
- type: "password",
92
- name: "key",
93
- message: "Paste your Anthropic API key (https://console.anthropic.com):",
94
- mask: "*",
95
- default: env.anthropicKey || config.anthropicKey || undefined,
96
- validate: (v) => v.startsWith("sk-") ? true : "Enter a key starting with sk-...",
97
- },
98
- {
99
- type: "input",
100
- name: "model",
101
- message: "Which Claude model?",
102
- default: config.providerType === "anthropic" && config.model
103
- ? config.model
104
- : DEFAULT_ANTHROPIC_MODEL,
105
- },
106
- ]);
107
- return {
108
- anthropicKey: key,
109
- model: model || DEFAULT_ANTHROPIC_MODEL,
110
- };
121
+ /**
122
+ * Model default: the value previously saved for this vendor, else its
123
+ * recommended flagship. openai-compat has none, so it starts blank and the
124
+ * required-non-empty validator catches blank submissions.
125
+ */
126
+ async function promptModelInput(vendor) {
127
+ const carriedOver = savedModelFor(vendor);
128
+ const recommended = vendor === "openai-compat" ? "" : RECOMMENDED_MODEL[vendor];
129
+ const defaultValue = carriedOver || recommended;
130
+ // openai-compat has no single recommended model — the scanner rasterizes
131
+ // PDFs to PNG on this path, so any non-vision model will fail on scan. Steer
132
+ // the user toward a vision-language model in the prompt.
133
+ const message = vendor === "openai-compat"
134
+ ? "Which AI model? (use a vision-language model)"
135
+ : `Which AI model? (recommended: ${RECOMMENDED_MODEL[vendor]})`;
136
+ return inputPrompt({
137
+ name: "model",
138
+ message,
139
+ default: defaultValue || undefined,
140
+ validate: (v) => v.trim().length > 0 || "Required",
141
+ });
111
142
  }
112
- async function promptOpenAICompatCredentials(env) {
113
- const { baseURL, apiKey, model } = await inquirer.prompt([
114
- {
115
- type: "input",
116
- name: "baseURL",
117
- message: "What's the base URL of your OpenAI-compatible server?",
118
- default: env.openaiBaseURL ||
119
- config.openaiCompatibleBaseURL ||
120
- DEFAULT_OPENAI_BASE_URL,
121
- validate: (v) => /^https?:\/\//.test(v) || "Must start with http:// or https://",
122
- },
123
- {
124
- type: "password",
125
- name: "apiKey",
126
- message: "API key (leave blank if your server doesn't need one):",
127
- mask: "*",
128
- default: env.openaiKey || config.openaiCompatibleKey || undefined,
129
- },
130
- {
131
- type: "input",
132
- name: "model",
133
- message: "Which model? (e.g. gpt-5, qwen3-coder:480b, deepseek-v3.1:671b)",
134
- default: config.providerType === "openai-compatible" && config.model
135
- ? config.model
136
- : "",
137
- validate: (v) => v.trim().length > 0 || "Required",
143
+ /**
144
+ * Empty submit silently keeps the existing key when one is on file. Otherwise
145
+ * the validator rejects empty (or accepts empty when `optional`, e.g. local
146
+ * servers that need no auth).
147
+ */
148
+ async function promptApiKey(opts) {
149
+ const hasExisting = opts.existing.length > 0;
150
+ const fresh = await passwordPrompt({
151
+ name: "key",
152
+ message: `${opts.label}:`,
153
+ validate: (v) => {
154
+ if (v === "" && (hasExisting || opts.optional))
155
+ return true;
156
+ if (opts.prefix && !v.startsWith(opts.prefix)) {
157
+ return `Enter a key starting with ${opts.prefix}...`;
158
+ }
159
+ if (v.length === 0)
160
+ return "Required";
161
+ return true;
138
162
  },
139
- ]);
163
+ });
164
+ return fresh === "" && hasExisting ? opts.existing : fresh;
165
+ }
166
+ async function promptAnthropicCredentials() {
167
+ const anthropicKey = await promptApiKey({
168
+ label: "Paste your Anthropic API key (https://console.anthropic.com)",
169
+ existing: config.anthropicKey,
170
+ prefix: "sk-",
171
+ });
172
+ const anthropicModel = await promptModelInput("anthropic");
173
+ return { providerType: "anthropic", anthropicKey, anthropicModel };
174
+ }
175
+ async function promptOpenAICredentials() {
176
+ const openaiKey = await promptApiKey({
177
+ label: "Paste your OpenAI API key (https://platform.openai.com/api-keys)",
178
+ existing: config.openaiKey,
179
+ prefix: "sk-",
180
+ });
181
+ const openaiModel = await promptModelInput("openai");
182
+ return { providerType: "openai", openaiKey, openaiModel };
183
+ }
184
+ async function promptGeminiCredentials() {
185
+ const geminiKey = await promptApiKey({
186
+ label: "Paste your Google AI Studio API key (https://aistudio.google.com/apikey)",
187
+ existing: config.geminiKey,
188
+ });
189
+ const geminiModel = await promptModelInput("gemini");
190
+ return { providerType: "gemini", geminiKey, geminiModel };
191
+ }
192
+ async function promptOpenAICompatCredentials() {
193
+ const baseURLDefault = config.providerType === "openai-compat" && config.openaiCompatBaseURL
194
+ ? config.openaiCompatBaseURL
195
+ : DEFAULT_LOCAL_OPENAI_BASE_URL;
196
+ const openaiCompatBaseURL = await inputPrompt({
197
+ name: "baseURL",
198
+ message: "What's the base URL of your LLM server?",
199
+ default: baseURLDefault,
200
+ validate: (v) => /^https?:\/\//.test(v) || "Must start with http:// or https://",
201
+ });
202
+ const openaiCompatKey = await promptApiKey({
203
+ label: "Paste your LLM server API key",
204
+ existing: config.openaiCompatKey,
205
+ optional: true,
206
+ });
207
+ const openaiCompatModel = await promptModelInput("openai-compat");
140
208
  return {
141
- openaiCompatibleBaseURL: baseURL,
142
- openaiCompatibleKey: apiKey || "",
143
- model: model.trim(),
209
+ providerType: "openai-compat",
210
+ openaiCompatBaseURL,
211
+ openaiCompatKey,
212
+ openaiCompatModel,
144
213
  };
145
214
  }
146
- async function promptCredentials(provider, env) {
147
- return provider === "openai-compatible"
148
- ? promptOpenAICompatCredentials(env)
149
- : promptAnthropicCredentials(env);
150
- }
151
- async function ensureEncryptionKey() {
152
- if (config.dbEncryptionKey) {
153
- console.log("");
154
- console.log(chalk.dim("Using the encryption key already on file."));
155
- return;
215
+ async function promptCredentials(vendor) {
216
+ switch (vendor) {
217
+ case "anthropic":
218
+ return promptAnthropicCredentials();
219
+ case "openai":
220
+ return promptOpenAICredentials();
221
+ case "gemini":
222
+ return promptGeminiCredentials();
223
+ case "openai-compat":
224
+ return promptOpenAICompatCredentials();
156
225
  }
157
- const mode = await listPrompt({
158
- name: "mode",
159
- message: "Encrypt the local database? (recommended)",
160
- choices: [
161
- { name: "Yes (generate a strong key automatically)", value: "auto" },
162
- { name: "Yes (I'll provide my own passphrase)", value: "manual" },
163
- { name: "No (store plaintext)", value: "none" },
164
- ],
165
- default: "auto",
166
- });
167
- if (mode === "auto") {
226
+ }
227
+ /**
228
+ * Encryption key is auto-generated. The work is microseconds,
229
+ * but the banner just told the user to "seal your vault" — hold the spinner
230
+ * so the step is visible.
231
+ */
232
+ async function sealVault() {
233
+ const spinner = statusSpinner("Sealing your vault…");
234
+ const start = Date.now();
235
+ if (!config.dbEncryptionKey) {
168
236
  saveConfig({ dbEncryptionKey: generateKey() });
169
- console.log(chalk.dim(`Generated a new DB encryption key and saved it to ${getConfigPath()}.`));
170
- return;
171
- }
172
- if (mode === "manual") {
173
- const { key: passphrase } = await inquirer.prompt([
174
- {
175
- type: "password",
176
- name: "key",
177
- message: "Choose a passphrase (at least 8 characters):",
178
- mask: "*",
179
- validate: (v) => v.length >= 8 || "Use at least 8 characters.",
180
- },
181
- ]);
182
- saveConfig({ dbEncryptionKey: passphrase });
183
237
  }
238
+ const remaining = 600 - (Date.now() - start);
239
+ if (remaining > 0)
240
+ await new Promise((r) => setTimeout(r, remaining));
241
+ spinner.succeed("Vault sealed.");
184
242
  }
185
243
  function finalizeDataDir(userName) {
186
244
  const dataDir = config.dataDir || resolve(getPlasalidDir(), "data");
@@ -192,16 +250,14 @@ function finalizeDataDir(userName) {
192
250
  export async function runSetup() {
193
251
  printBanner();
194
252
  ensureDir(getPlasalidDir());
195
- const env = readEnvDefaults();
196
- const userName = await promptUserName(env);
197
- const provider = await promptProviderChoice();
198
- const credentials = await promptCredentials(provider, env);
253
+ const userName = await promptUserName();
254
+ const vendor = await promptProviderChoice();
255
+ const credentials = await promptCredentials(vendor);
199
256
  saveConfig({
200
- providerType: provider,
201
257
  userName: userName || "User",
202
258
  ...credentials,
203
259
  });
204
- await ensureEncryptionKey();
260
+ await sealVault();
205
261
  const dataDir = finalizeDataDir(userName || "User");
206
262
  printSummary(dataDir);
207
263
  }
package/dist/config.d.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  import "dotenv/config";
2
2
  export interface PlasalidConfig {
3
+ providerType: "anthropic" | "openai" | "gemini" | "openai-compat";
3
4
  anthropicKey: string;
4
- model: string;
5
- providerType: "anthropic" | "openai-compatible";
6
- openaiCompatibleKey: string;
7
- openaiCompatibleBaseURL: string;
5
+ anthropicModel: string;
6
+ openaiKey: string;
7
+ openaiModel: string;
8
+ geminiKey: string;
9
+ geminiModel: string;
10
+ openaiCompatKey: string;
11
+ openaiCompatBaseURL: string;
12
+ openaiCompatModel: string;
8
13
  displayLocale: string;
9
14
  displayCurrency: string;
10
15
  dbPath: string;
@@ -18,4 +23,5 @@ export declare function getConfigPath(): string;
18
23
  export declare function getDataDir(): string;
19
24
  export declare const config: PlasalidConfig;
20
25
  export declare function isConfigured(): boolean;
26
+ export declare function getActiveModel(): string;
21
27
  export declare function saveConfig(partial: Partial<PlasalidConfig>): void;
package/dist/config.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import "dotenv/config";
2
- import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "fs";
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, } from "fs";
3
3
  import { resolve } from "path";
4
4
  import { homedir } from "os";
5
5
  const PLASALID_DIR = resolve(homedir(), ".plasalid");
@@ -28,28 +28,55 @@ function buildConfig() {
28
28
  // Precedence: env > file > default. Env is checked first so a shell-exported
29
29
  // override always wins over whatever is in ~/.plasalid/config.json.
30
30
  return {
31
- anthropicKey: process.env.ANTHROPIC_API_KEY || file.anthropicKey || "",
32
- model: process.env.PLASALID_MODEL || file.model || "claude-sonnet-4-6",
33
31
  providerType: process.env.PLASALID_PROVIDER ||
34
32
  file.providerType ||
35
33
  "anthropic",
36
- openaiCompatibleKey: process.env.OPENAI_COMPATIBLE_API_KEY || file.openaiCompatibleKey || "",
37
- openaiCompatibleBaseURL: process.env.OPENAI_COMPATIBLE_BASE_URL || file.openaiCompatibleBaseURL || "",
34
+ anthropicKey: process.env.ANTHROPIC_API_KEY || file.anthropicKey || "",
35
+ anthropicModel: process.env.ANTHROPIC_MODEL || file.anthropicModel || "claude-sonnet-4-6",
36
+ openaiKey: process.env.OPENAI_API_KEY || file.openaiKey || "",
37
+ openaiModel: process.env.OPENAI_MODEL || file.openaiModel || "gpt-5.4-mini",
38
+ openaiCompatKey: process.env.OPENAI_COMPAT_API_KEY || file.openaiCompatKey || "",
39
+ openaiCompatBaseURL: process.env.OPENAI_COMPAT_BASE_URL || file.openaiCompatBaseURL || "",
40
+ openaiCompatModel: process.env.OPENAI_COMPAT_MODEL || file.openaiCompatModel || "",
41
+ geminiKey: process.env.GEMINI_API_KEY || file.geminiKey || "",
42
+ geminiModel: process.env.GEMINI_MODEL || file.geminiModel || "gemini-2.5-pro",
38
43
  displayLocale: file.displayLocale || "th-TH",
39
44
  displayCurrency: file.displayCurrency || "THB",
40
- dbPath: process.env.PLASALID_DB_PATH || file.dbPath || resolve(PLASALID_DIR, "db.sqlite"),
45
+ dbPath: process.env.PLASALID_DB_PATH ||
46
+ file.dbPath ||
47
+ resolve(PLASALID_DIR, "db.sqlite"),
41
48
  dbEncryptionKey: process.env.PLASALID_DB_ENCRYPTION_KEY || file.dbEncryptionKey || "",
42
- dataDir: process.env.PLASALID_DATA_DIR || file.dataDir || resolve(PLASALID_DIR, "data"),
49
+ dataDir: process.env.PLASALID_DATA_DIR ||
50
+ file.dataDir ||
51
+ resolve(PLASALID_DIR, "data"),
43
52
  userName: file.userName || "User",
44
53
  thinkingBudget: file.thinkingBudget ?? 8000,
45
54
  };
46
55
  }
47
56
  export const config = buildConfig();
48
57
  export function isConfigured() {
49
- if (config.providerType === "openai-compatible") {
50
- return !!config.openaiCompatibleBaseURL;
58
+ switch (config.providerType) {
59
+ case "anthropic":
60
+ return !!config.anthropicKey;
61
+ case "openai":
62
+ return !!config.openaiKey;
63
+ case "gemini":
64
+ return !!config.geminiKey;
65
+ case "openai-compat":
66
+ return !!config.openaiCompatBaseURL;
67
+ }
68
+ }
69
+ export function getActiveModel() {
70
+ switch (config.providerType) {
71
+ case "anthropic":
72
+ return config.anthropicModel;
73
+ case "openai":
74
+ return config.openaiModel;
75
+ case "gemini":
76
+ return config.geminiModel;
77
+ case "openai-compat":
78
+ return config.openaiCompatModel;
51
79
  }
52
- return !!config.anthropicKey;
53
80
  }
54
81
  export function saveConfig(partial) {
55
82
  const configPath = getConfigPath();
@@ -57,7 +84,9 @@ export function saveConfig(partial) {
57
84
  mkdirSync(PLASALID_DIR, { recursive: true });
58
85
  const existing = loadFileConfig();
59
86
  const merged = { ...existing, ...partial };
60
- writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n", { mode: 0o600 });
87
+ writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n", {
88
+ mode: 0o600,
89
+ });
61
90
  try {
62
91
  chmodSync(configPath, 0o600);
63
92
  }
@@ -28,6 +28,8 @@ export interface RunClarifyOpts {
28
28
  toolCount: number;
29
29
  elapsedMs: number;
30
30
  }) => void;
31
+ /** When set and aborted, runClarify stops between passes/questions. */
32
+ signal?: AbortSignal;
31
33
  }
32
34
  export declare const CLARIFIER_PASSES: readonly ClarifierPass[];
33
35
  /**
@@ -156,6 +156,7 @@ async function runAgentLoop(opts, closures, tally) {
156
156
  },
157
157
  },
158
158
  onProgress: opts.onProgress,
159
+ signal: opts.signal,
159
160
  });
160
161
  return countRemaining(db, opts.scanId);
161
162
  },
@@ -4,8 +4,15 @@
4
4
  * never aborts the rest — its slot settles as `{ ok: false, error }` and the
5
5
  * caller decides what to do.
6
6
  *
7
+ * Pass `signal` to make the pool cancellation-aware: when it aborts, no new
8
+ * task is claimed (tasks already running aren't interrupted — their own
9
+ * signal-aware work is expected to react). Unclaimed slots stay `undefined`
10
+ * in the returned array; the caller can spot them by checking length vs the
11
+ * filled entries.
12
+ *
7
13
  * No new dependency. Simple worker-pool: kicks off up to `n` tasks, then each
8
- * worker pulls the next index from a shared cursor until the queue is drained.
14
+ * worker pulls the next index from a shared cursor until the queue is drained
15
+ * or the signal aborts.
9
16
  */
10
17
  export type Settled<T> = {
11
18
  ok: true;
@@ -14,4 +21,4 @@ export type Settled<T> = {
14
21
  ok: false;
15
22
  error: unknown;
16
23
  };
17
- export declare function runWithConcurrency<T>(tasks: Array<() => Promise<T>>, n: number): Promise<Settled<T>[]>;
24
+ export declare function runWithConcurrency<T>(tasks: Array<() => Promise<T>>, n: number, signal?: AbortSignal): Promise<Settled<T>[]>;
@@ -1,9 +1,11 @@
1
- export async function runWithConcurrency(tasks, n) {
1
+ export async function runWithConcurrency(tasks, n, signal) {
2
2
  const results = new Array(tasks.length);
3
3
  const workerCount = Math.max(1, Math.min(n, tasks.length));
4
4
  let cursor = 0;
5
5
  async function worker() {
6
6
  while (cursor < tasks.length) {
7
+ if (signal?.aborted)
8
+ return;
7
9
  const index = cursor++;
8
10
  try {
9
11
  results[index] = { ok: true, value: await tasks[index]() };
@@ -65,6 +65,7 @@ export interface ScanState {
65
65
  readonly startedAt: number;
66
66
  readonly options: RunScanOptions;
67
67
  readonly progress: ScanProgress;
68
+ readonly signal: AbortSignal;
68
69
  files: ScannedFile[];
69
70
  decrypted: DecryptedFile[];
70
71
  skipped: SkippedFile[];
@@ -87,4 +88,4 @@ export declare const DEFAULT_PHASES: readonly {
87
88
  * through ScanState, then runs the phase chain. Nothing survives between
88
89
  * scans.
89
90
  */
90
- export declare function runScan(db: Database.Database, opts?: RunScanOptions, hooks?: ScanHooks): Promise<ScanResult>;
91
+ export declare function runScan(db: Database.Database, opts?: RunScanOptions, hooks?: ScanHooks, signal?: AbortSignal): Promise<ScanResult>;
@@ -5,6 +5,9 @@ import { parsePhase } from "./parse.js";
5
5
  import { chunkPdf } from "./pdf/chunker.js";
6
6
  import { runClarify } from "./clarifier.js";
7
7
  import { errorMessage } from "./result.js";
8
+ import { AbortedError } from "../ai/errors.js";
9
+ /** A signal that never aborts. Used when callers don't pass one. */
10
+ const NEVER_ABORTS = new AbortController().signal;
8
11
  const chunkPhase = async (_db, state, hooks) => {
9
12
  await hooks.beforeChunk?.(state);
10
13
  for (const file of state.decrypted)
@@ -17,6 +20,7 @@ const clarifyPhase = async (db, state, hooks) => {
17
20
  db,
18
21
  scanId: state.scanId,
19
22
  interactive: state.options.interactive ?? true,
23
+ signal: state.signal,
20
24
  });
21
25
  state.clarifySummary = summary;
22
26
  await hooks.afterClarify?.(state, summary);
@@ -32,7 +36,7 @@ export const DEFAULT_PHASES = [
32
36
  * through ScanState, then runs the phase chain. Nothing survives between
33
37
  * scans.
34
38
  */
35
- export async function runScan(db, opts = {}, hooks = {}) {
39
+ export async function runScan(db, opts = {}, hooks = {}, signal = NEVER_ABORTS) {
36
40
  const scanId = `sc:${randomUUID()}`;
37
41
  const progress = createProgress();
38
42
  const state = {
@@ -40,6 +44,7 @@ export async function runScan(db, opts = {}, hooks = {}) {
40
44
  startedAt: Date.now(),
41
45
  options: opts,
42
46
  progress,
47
+ signal,
43
48
  files: [],
44
49
  decrypted: [],
45
50
  skipped: [],
@@ -50,8 +55,19 @@ export async function runScan(db, opts = {}, hooks = {}) {
50
55
  };
51
56
  await fire(hooks.onStart, state);
52
57
  const phases = opts.phases ?? DEFAULT_PHASES;
53
- await runPhaseChain(db, state, hooks, phases);
54
- await fire(hooks.onFinish, state);
58
+ try {
59
+ await runPhaseChain(db, state, hooks, phases);
60
+ if (state.signal.aborted)
61
+ throw new AbortedError();
62
+ }
63
+ catch (err) {
64
+ if (err instanceof AbortedError)
65
+ await fire(hooks.onAbort, state);
66
+ throw err;
67
+ }
68
+ finally {
69
+ await fire(hooks.onFinish, state);
70
+ }
55
71
  return { scanId, state };
56
72
  }
57
73
  async function runPhaseChain(db, state, hooks, phases) {
@@ -67,6 +83,8 @@ async function tryPhase(db, state, hooks, name, phase) {
67
83
  return false;
68
84
  }
69
85
  catch (err) {
86
+ if (err instanceof AbortedError)
87
+ throw err;
70
88
  state.errors.push({ phase: name, error: err });
71
89
  await fire(hooks.onError, err, name, state);
72
90
  return true;