plasalid 0.7.9 → 0.8.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.
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-3.5-flash",
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,187 @@ 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
+ return inputPrompt({
131
+ name: "model",
132
+ message: "Which AI model?",
133
+ default: defaultValue || undefined,
134
+ validate: (v) => v.trim().length > 0 || "Required",
135
+ });
111
136
  }
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",
137
+ /**
138
+ * Empty submit silently keeps the existing key when one is on file. Otherwise
139
+ * the validator rejects empty (or accepts empty when `optional`, e.g. local
140
+ * servers that need no auth).
141
+ */
142
+ async function promptApiKey(opts) {
143
+ const hasExisting = opts.existing.length > 0;
144
+ const fresh = await passwordPrompt({
145
+ name: "key",
146
+ message: `${opts.label}:`,
147
+ validate: (v) => {
148
+ if (v === "" && (hasExisting || opts.optional))
149
+ return true;
150
+ if (opts.prefix && !v.startsWith(opts.prefix)) {
151
+ return `Enter a key starting with ${opts.prefix}...`;
152
+ }
153
+ if (v.length === 0)
154
+ return "Required";
155
+ return true;
138
156
  },
139
- ]);
157
+ });
158
+ return fresh === "" && hasExisting ? opts.existing : fresh;
159
+ }
160
+ async function promptAnthropicCredentials() {
161
+ const anthropicKey = await promptApiKey({
162
+ label: "Paste your Anthropic API key (https://console.anthropic.com)",
163
+ existing: config.anthropicKey,
164
+ prefix: "sk-",
165
+ });
166
+ const anthropicModel = await promptModelInput("anthropic");
167
+ return { providerType: "anthropic", anthropicKey, anthropicModel };
168
+ }
169
+ async function promptOpenAICredentials() {
170
+ const openaiKey = await promptApiKey({
171
+ label: "Paste your OpenAI API key (https://platform.openai.com/api-keys)",
172
+ existing: config.openaiKey,
173
+ prefix: "sk-",
174
+ });
175
+ const openaiModel = await promptModelInput("openai");
176
+ return { providerType: "openai", openaiKey, openaiModel };
177
+ }
178
+ async function promptGeminiCredentials() {
179
+ const geminiKey = await promptApiKey({
180
+ label: "Paste your Google AI Studio API key (https://aistudio.google.com/apikey)",
181
+ existing: config.geminiKey,
182
+ });
183
+ const geminiModel = await promptModelInput("gemini");
184
+ return { providerType: "gemini", geminiKey, geminiModel };
185
+ }
186
+ async function promptOpenAICompatCredentials() {
187
+ const baseURLDefault = config.providerType === "openai-compat" && config.openaiCompatBaseURL
188
+ ? config.openaiCompatBaseURL
189
+ : DEFAULT_LOCAL_OPENAI_BASE_URL;
190
+ const openaiCompatBaseURL = await inputPrompt({
191
+ name: "baseURL",
192
+ message: "What's the base URL of your LLM server?",
193
+ default: baseURLDefault,
194
+ validate: (v) => /^https?:\/\//.test(v) || "Must start with http:// or https://",
195
+ });
196
+ const openaiCompatKey = await promptApiKey({
197
+ label: "Paste your LLM server API key",
198
+ existing: config.openaiCompatKey,
199
+ optional: true,
200
+ });
201
+ const openaiCompatModel = await promptModelInput("openai-compat");
140
202
  return {
141
- openaiCompatibleBaseURL: baseURL,
142
- openaiCompatibleKey: apiKey || "",
143
- model: model.trim(),
203
+ providerType: "openai-compat",
204
+ openaiCompatBaseURL,
205
+ openaiCompatKey,
206
+ openaiCompatModel,
144
207
  };
145
208
  }
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;
209
+ async function promptCredentials(vendor) {
210
+ switch (vendor) {
211
+ case "anthropic":
212
+ return promptAnthropicCredentials();
213
+ case "openai":
214
+ return promptOpenAICredentials();
215
+ case "gemini":
216
+ return promptGeminiCredentials();
217
+ case "openai-compat":
218
+ return promptOpenAICompatCredentials();
156
219
  }
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") {
220
+ }
221
+ /**
222
+ * Encryption key is auto-generated. The work is microseconds,
223
+ * but the banner just told the user to "seal your vault" — hold the spinner
224
+ * so the step is visible.
225
+ */
226
+ async function sealVault() {
227
+ const spinner = statusSpinner("Sealing your vault…");
228
+ const start = Date.now();
229
+ if (!config.dbEncryptionKey) {
168
230
  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
231
  }
232
+ const remaining = 600 - (Date.now() - start);
233
+ if (remaining > 0)
234
+ await new Promise((r) => setTimeout(r, remaining));
235
+ spinner.succeed("Vault sealed.");
184
236
  }
185
237
  function finalizeDataDir(userName) {
186
238
  const dataDir = config.dataDir || resolve(getPlasalidDir(), "data");
@@ -192,16 +244,14 @@ function finalizeDataDir(userName) {
192
244
  export async function runSetup() {
193
245
  printBanner();
194
246
  ensureDir(getPlasalidDir());
195
- const env = readEnvDefaults();
196
- const userName = await promptUserName(env);
197
- const provider = await promptProviderChoice();
198
- const credentials = await promptCredentials(provider, env);
247
+ const userName = await promptUserName();
248
+ const vendor = await promptProviderChoice();
249
+ const credentials = await promptCredentials(vendor);
199
250
  saveConfig({
200
- providerType: provider,
201
251
  userName: userName || "User",
202
252
  ...credentials,
203
253
  });
204
- await ensureEncryptionKey();
254
+ await sealVault();
205
255
  const dataDir = finalizeDataDir(userName || "User");
206
256
  printSummary(dataDir);
207
257
  }
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-3.5-flash",
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
  }
@@ -1,4 +1,5 @@
1
- import type { DocumentBlock } from "../../ai/provider.js";
1
+ import type { DocumentBlock, ImageBlock, Provider } from "../../ai/provider.js";
2
+ import type { Chunk } from "../engine.js";
2
3
  export interface LoadedFile {
3
4
  bytes: Buffer;
4
5
  hash: string;
@@ -13,5 +14,5 @@ export interface LoadedFile {
13
14
  * recognize the same file across re-scans regardless of unlock state.
14
15
  */
15
16
  export declare function readPdf(path: string): LoadedFile;
16
- /** Build an Anthropic-compatible document content block from PDF bytes. */
17
17
  export declare function buildDocumentBlock(bytes: Buffer, fileName: string, mime?: string): DocumentBlock;
18
+ export declare function buildScanAttachment(chunk: Chunk, provider: Provider): Promise<DocumentBlock | ImageBlock>;
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, statSync } from "fs";
2
2
  import { createHash } from "crypto";
3
3
  import { basename, extname } from "path";
4
+ import { rasterizePage } from "./rasterize.js";
4
5
  const MIME_BY_EXT = {
5
6
  ".pdf": "application/pdf",
6
7
  };
@@ -26,7 +27,6 @@ export function readPdf(path) {
26
27
  const hash = createHash("sha256").update(bytes).digest("hex");
27
28
  return { bytes, hash, mime, fileName: basename(path) };
28
29
  }
29
- /** Build an Anthropic-compatible document content block from PDF bytes. */
30
30
  export function buildDocumentBlock(bytes, fileName, mime = "application/pdf") {
31
31
  return {
32
32
  type: "document",
@@ -34,3 +34,13 @@ export function buildDocumentBlock(bytes, fileName, mime = "application/pdf") {
34
34
  title: fileName,
35
35
  };
36
36
  }
37
+ export async function buildScanAttachment(chunk, provider) {
38
+ if (provider.acceptsDocuments) {
39
+ return buildDocumentBlock(chunk.bytes, chunk.fileName, chunk.mime);
40
+ }
41
+ const { bytes, mime } = await rasterizePage(chunk.bytes);
42
+ return {
43
+ type: "image",
44
+ source: { type: "base64", media_type: mime, data: bytes.toString("base64") },
45
+ };
46
+ }
@@ -0,0 +1,6 @@
1
+ export declare function rasterizePage(pdfBytes: Buffer, opts?: {
2
+ dpi?: number;
3
+ }): Promise<{
4
+ bytes: Buffer;
5
+ mime: "image/png";
6
+ }>;
@@ -0,0 +1,36 @@
1
+ let mupdfPromise = null;
2
+ function getMupdf() {
3
+ if (!mupdfPromise)
4
+ mupdfPromise = import("mupdf");
5
+ return mupdfPromise;
6
+ }
7
+ /**
8
+ * 150 DPI keeps statement numerals readable to a VL model without blowing
9
+ * up the token bill on a dense page.
10
+ */
11
+ const DEFAULT_DPI = 150;
12
+ export async function rasterizePage(pdfBytes, opts = {}) {
13
+ const mupdf = await getMupdf();
14
+ const dpi = opts.dpi ?? DEFAULT_DPI;
15
+ const scale = dpi / 72;
16
+ const doc = mupdf.Document.openDocument(pdfBytes, "application/pdf");
17
+ try {
18
+ const page = doc.loadPage(0);
19
+ try {
20
+ const pixmap = page.toPixmap(mupdf.Matrix.scale(scale, scale), mupdf.ColorSpace.DeviceRGB, false);
21
+ try {
22
+ const png = pixmap.asPNG();
23
+ return { bytes: Buffer.from(png), mime: "image/png" };
24
+ }
25
+ finally {
26
+ pixmap.destroy();
27
+ }
28
+ }
29
+ finally {
30
+ page.destroy();
31
+ }
32
+ }
33
+ finally {
34
+ doc.destroy();
35
+ }
36
+ }
@@ -1,7 +1,8 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import { runScanAgent } from "../ai/agent.js";
3
+ import { getProvider } from "../ai/providers/index.js";
3
4
  import { recordQuestion } from "../db/queries/questions.js";
4
- import { buildDocumentBlock } from "./pdf/pdf.js";
5
+ import { buildScanAttachment } from "./pdf/pdf.js";
5
6
  import { tryExecute } from "./result.js";
6
7
  /**
7
8
  * Process one chunk: run the LLM scan agent over a single-page PDF blob with
@@ -13,13 +14,14 @@ import { tryExecute } from "./result.js";
13
14
  export async function runScanWorker(deps, hooks) {
14
15
  const workerId = `cw:${randomUUID()}`;
15
16
  hooks.onWorkerStart?.(workerId, deps.chunk);
17
+ const attachment = await buildScanAttachment(deps.chunk, getProvider());
16
18
  const outcome = await tryExecute(() => runScanAgent({
17
19
  db: deps.db,
18
20
  initialMessages: [
19
21
  {
20
22
  role: "user",
21
23
  content: [
22
- buildDocumentBlock(deps.chunk.bytes, deps.chunk.fileName, deps.chunk.mime),
24
+ attachment,
23
25
  { type: "text", text: buildChunkPrompt(deps.chunk) },
24
26
  ],
25
27
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plasalid",
3
- "version": "0.7.9",
3
+ "version": "0.8.0",
4
4
  "description": "Plasalid — The Harness Layer for Personal Finance",
5
5
  "keywords": [
6
6
  "finance",
@@ -41,6 +41,7 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@anthropic-ai/sdk": "^0.74.0",
44
+ "@google/genai": "^2.6.0",
44
45
  "chalk": "^5.3.0",
45
46
  "commander": "^13.0.0",
46
47
  "dotenv": "^16.4.0",