plasalid 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/LICENSE +213 -0
  2. package/README.md +176 -0
  3. package/dist/accounts/taxonomy.d.ts +31 -0
  4. package/dist/accounts/taxonomy.js +189 -0
  5. package/dist/ai/agent.d.ts +43 -0
  6. package/dist/ai/agent.js +155 -0
  7. package/dist/ai/context.d.ts +4 -0
  8. package/dist/ai/context.js +33 -0
  9. package/dist/ai/memory.d.ts +14 -0
  10. package/dist/ai/memory.js +12 -0
  11. package/dist/ai/provider.d.ts +67 -0
  12. package/dist/ai/provider.js +5 -0
  13. package/dist/ai/providers/anthropic.d.ts +5 -0
  14. package/dist/ai/providers/anthropic.js +49 -0
  15. package/dist/ai/providers/index.d.ts +2 -0
  16. package/dist/ai/providers/index.js +12 -0
  17. package/dist/ai/providers/openai-compat.d.ts +5 -0
  18. package/dist/ai/providers/openai-compat.js +147 -0
  19. package/dist/ai/providers/openai.d.ts +5 -0
  20. package/dist/ai/providers/openai.js +147 -0
  21. package/dist/ai/redactor.d.ts +2 -0
  22. package/dist/ai/redactor.js +91 -0
  23. package/dist/ai/sanitize.d.ts +14 -0
  24. package/dist/ai/sanitize.js +25 -0
  25. package/dist/ai/system-prompt.d.ts +13 -0
  26. package/dist/ai/system-prompt.js +174 -0
  27. package/dist/ai/thai-taxonomy-hint.d.ts +8 -0
  28. package/dist/ai/thai-taxonomy-hint.js +22 -0
  29. package/dist/ai/thinking-phrases.d.ts +7 -0
  30. package/dist/ai/thinking-phrases.js +15 -0
  31. package/dist/ai/thinking.d.ts +7 -0
  32. package/dist/ai/thinking.js +15 -0
  33. package/dist/ai/tools/common.d.ts +2 -0
  34. package/dist/ai/tools/common.js +83 -0
  35. package/dist/ai/tools/index.d.ts +8 -0
  36. package/dist/ai/tools/index.js +34 -0
  37. package/dist/ai/tools/ingest.d.ts +2 -0
  38. package/dist/ai/tools/ingest.js +202 -0
  39. package/dist/ai/tools/read.d.ts +2 -0
  40. package/dist/ai/tools/read.js +123 -0
  41. package/dist/ai/tools/reconcile.d.ts +2 -0
  42. package/dist/ai/tools/reconcile.js +227 -0
  43. package/dist/ai/tools/scan.d.ts +2 -0
  44. package/dist/ai/tools/scan.js +24 -0
  45. package/dist/ai/tools/types.d.ts +26 -0
  46. package/dist/ai/tools/types.js +1 -0
  47. package/dist/ai/tools.d.ts +18 -0
  48. package/dist/ai/tools.js +402 -0
  49. package/dist/cli/chat.d.ts +1 -0
  50. package/dist/cli/chat.js +28 -0
  51. package/dist/cli/commands/accounts.d.ts +1 -0
  52. package/dist/cli/commands/accounts.js +86 -0
  53. package/dist/cli/commands/data.d.ts +1 -0
  54. package/dist/cli/commands/data.js +28 -0
  55. package/dist/cli/commands/reconcile.d.ts +2 -0
  56. package/dist/cli/commands/reconcile.js +15 -0
  57. package/dist/cli/commands/revert.d.ts +1 -0
  58. package/dist/cli/commands/revert.js +68 -0
  59. package/dist/cli/commands/scan.d.ts +4 -0
  60. package/dist/cli/commands/scan.js +45 -0
  61. package/dist/cli/commands/status.d.ts +1 -0
  62. package/dist/cli/commands/status.js +22 -0
  63. package/dist/cli/commands/transactions.d.ts +8 -0
  64. package/dist/cli/commands/transactions.js +92 -0
  65. package/dist/cli/commands/undo.d.ts +1 -0
  66. package/dist/cli/commands/undo.js +38 -0
  67. package/dist/cli/commands.d.ts +14 -0
  68. package/dist/cli/commands.js +196 -0
  69. package/dist/cli/format.d.ts +8 -0
  70. package/dist/cli/format.js +109 -0
  71. package/dist/cli/index.d.ts +2 -0
  72. package/dist/cli/index.js +126 -0
  73. package/dist/cli/ink/ChatApp.d.ts +8 -0
  74. package/dist/cli/ink/ChatApp.js +94 -0
  75. package/dist/cli/ink/PromptFrame.d.ts +10 -0
  76. package/dist/cli/ink/PromptFrame.js +11 -0
  77. package/dist/cli/ink/TextInput.d.ts +13 -0
  78. package/dist/cli/ink/TextInput.js +24 -0
  79. package/dist/cli/ink/hooks/useAgent.d.ts +27 -0
  80. package/dist/cli/ink/hooks/useAgent.js +65 -0
  81. package/dist/cli/ink/hooks/useCtrlCExit.d.ts +16 -0
  82. package/dist/cli/ink/hooks/useCtrlCExit.js +43 -0
  83. package/dist/cli/ink/hooks/useFooterText.d.ts +2 -0
  84. package/dist/cli/ink/hooks/useFooterText.js +43 -0
  85. package/dist/cli/ink/hooks/useTextInput.d.ts +32 -0
  86. package/dist/cli/ink/hooks/useTextInput.js +356 -0
  87. package/dist/cli/ink/messages/AssistantMessage.d.ts +3 -0
  88. package/dist/cli/ink/messages/AssistantMessage.js +6 -0
  89. package/dist/cli/ink/messages/ErrorMessage.d.ts +4 -0
  90. package/dist/cli/ink/messages/ErrorMessage.js +6 -0
  91. package/dist/cli/ink/messages/InterruptedMessage.d.ts +1 -0
  92. package/dist/cli/ink/messages/InterruptedMessage.js +6 -0
  93. package/dist/cli/ink/messages/ThinkingLine.d.ts +12 -0
  94. package/dist/cli/ink/messages/ThinkingLine.js +23 -0
  95. package/dist/cli/ink/messages/UserMessage.d.ts +4 -0
  96. package/dist/cli/ink/messages/UserMessage.js +15 -0
  97. package/dist/cli/ink/mount.d.ts +6 -0
  98. package/dist/cli/ink/mount.js +12 -0
  99. package/dist/cli/logo.d.ts +1 -0
  100. package/dist/cli/logo.js +20 -0
  101. package/dist/cli/setup.d.ts +2 -0
  102. package/dist/cli/setup.js +210 -0
  103. package/dist/cli/ux.d.ts +38 -0
  104. package/dist/cli/ux.js +104 -0
  105. package/dist/config.d.ts +21 -0
  106. package/dist/config.js +66 -0
  107. package/dist/currency.d.ts +6 -0
  108. package/dist/currency.js +19 -0
  109. package/dist/db/connection.d.ts +5 -0
  110. package/dist/db/connection.js +45 -0
  111. package/dist/db/encryption.d.ts +11 -0
  112. package/dist/db/encryption.js +45 -0
  113. package/dist/db/helpers.d.ts +16 -0
  114. package/dist/db/helpers.js +45 -0
  115. package/dist/db/queries/account_balance.d.ts +61 -0
  116. package/dist/db/queries/account_balance.js +146 -0
  117. package/dist/db/queries/journal.d.ts +95 -0
  118. package/dist/db/queries/journal.js +204 -0
  119. package/dist/db/queries/search.d.ts +7 -0
  120. package/dist/db/queries/search.js +19 -0
  121. package/dist/db/schema.d.ts +2 -0
  122. package/dist/db/schema.js +95 -0
  123. package/dist/index.d.ts +1 -0
  124. package/dist/index.js +1 -0
  125. package/dist/parser/pdf.d.ts +14 -0
  126. package/dist/parser/pdf.js +40 -0
  127. package/dist/parser/pipeline.d.ts +44 -0
  128. package/dist/parser/pipeline.js +160 -0
  129. package/dist/parser/prompts.d.ts +8 -0
  130. package/dist/parser/prompts.js +20 -0
  131. package/dist/parser/walker.d.ts +8 -0
  132. package/dist/parser/walker.js +42 -0
  133. package/dist/reconciler/pipeline.d.ts +17 -0
  134. package/dist/reconciler/pipeline.js +45 -0
  135. package/dist/reconciler/prompts.d.ts +12 -0
  136. package/dist/reconciler/prompts.js +22 -0
  137. package/dist/scanner/password-store.d.ts +34 -0
  138. package/dist/scanner/password-store.js +83 -0
  139. package/dist/scanner/pdf-unlock.d.ts +17 -0
  140. package/dist/scanner/pdf-unlock.js +48 -0
  141. package/dist/scanner/pdf.d.ts +17 -0
  142. package/dist/scanner/pdf.js +36 -0
  143. package/dist/scanner/pipeline.d.ts +32 -0
  144. package/dist/scanner/pipeline.js +137 -0
  145. package/dist/scanner/prompts.d.ts +8 -0
  146. package/dist/scanner/prompts.js +20 -0
  147. package/dist/scanner/state-machine.d.ts +60 -0
  148. package/dist/scanner/state-machine.js +64 -0
  149. package/dist/scanner/unlock.d.ts +24 -0
  150. package/dist/scanner/unlock.js +122 -0
  151. package/dist/scanner/walker.d.ts +8 -0
  152. package/dist/scanner/walker.js +42 -0
  153. package/package.json +65 -0
@@ -0,0 +1,210 @@
1
+ import chalk from "chalk";
2
+ import inquirer from "inquirer";
3
+ import { existsSync, mkdirSync } from "fs";
4
+ import { resolve } from "path";
5
+ import { config, saveConfig, getConfigPath, isConfigured, getPlasalidDir, getDataDir, } from "../config.js";
6
+ import { generateKey } from "../db/encryption.js";
7
+ import { createContextTemplate } from "../ai/context.js";
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";
11
+ function ensureDir(p) {
12
+ if (!existsSync(p))
13
+ mkdirSync(p, { recursive: true });
14
+ }
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
+ function printBanner() {
24
+ console.log("");
25
+ printLogo();
26
+ console.log("");
27
+ console.log("Welcome to Plasalid. Let's get you set up — a few quick questions.");
28
+ console.log("");
29
+ }
30
+ function printSummary(dataDir) {
31
+ console.log("");
32
+ console.log(chalk.green("✓ Plasalid is configured."));
33
+ console.log(chalk.dim(`Config: ${getConfigPath()}`));
34
+ console.log(chalk.dim(`Data: ${dataDir}`));
35
+ console.log("");
36
+ console.log("Next steps:");
37
+ console.log(` 1. Run ${chalk.cyan("plasalid data")} to drop your bank/credit card statments PDFs in.`);
38
+ console.log(` 2. Run ${chalk.cyan("plasalid scan")} to allow Plasalid to scan them.`);
39
+ console.log(` 3. Run ${chalk.cyan("plasalid")} to chat with your financial data.`);
40
+ }
41
+ /**
42
+ * Wraps inquirer's list prompt with a blank line above and below, and inserts
43
+ * a Separator(" ") row above the first choice so the question and the first
44
+ * option don't crowd each other. Mirrors `makePromptUser` in `src/cli/ux.ts`.
45
+ */
46
+ async function listPrompt(opts) {
47
+ console.log("");
48
+ const answer = await inquirer.prompt([
49
+ {
50
+ type: "list",
51
+ name: opts.name,
52
+ message: opts.message,
53
+ choices: [new inquirer.Separator(" "), ...opts.choices],
54
+ default: opts.default,
55
+ },
56
+ ]);
57
+ console.log("");
58
+ return answer[opts.name];
59
+ }
60
+ async function promptUserName(env) {
61
+ const { userName } = await inquirer.prompt([
62
+ {
63
+ type: "input",
64
+ name: "userName",
65
+ message: "What should I call you? (Your name)",
66
+ default: env.userName || (config.userName === "User" ? "" : config.userName),
67
+ },
68
+ ]);
69
+ return String(userName || "").trim();
70
+ }
71
+ async function promptProviderChoice() {
72
+ return listPrompt({
73
+ name: "providerChoice",
74
+ message: "Which AI provider would you like to use?",
75
+ choices: [
76
+ { name: "Anthropic (Claude)", value: "anthropic" },
77
+ {
78
+ name: "OpenAI compatible (OpenAI, LM Studio, vLLM, Ollama)",
79
+ value: "openai-compatible",
80
+ },
81
+ ],
82
+ default: "anthropic",
83
+ });
84
+ }
85
+ async function promptAnthropicCredentials(env) {
86
+ const { key, model } = await inquirer.prompt([
87
+ {
88
+ type: "password",
89
+ name: "key",
90
+ message: "Paste your Anthropic API key (https://console.anthropic.com):",
91
+ mask: "*",
92
+ default: env.anthropicKey || config.anthropicKey || undefined,
93
+ validate: (v) => v.startsWith("sk-") ? true : "Enter a key starting with sk-...",
94
+ },
95
+ {
96
+ type: "input",
97
+ name: "model",
98
+ message: "Which Claude model?",
99
+ default: config.providerType === "anthropic" && config.model
100
+ ? config.model
101
+ : DEFAULT_ANTHROPIC_MODEL,
102
+ },
103
+ ]);
104
+ return {
105
+ anthropicKey: key,
106
+ model: model || DEFAULT_ANTHROPIC_MODEL,
107
+ };
108
+ }
109
+ async function promptOpenAICompatCredentials(env) {
110
+ const { baseURL, apiKey, model } = await inquirer.prompt([
111
+ {
112
+ type: "input",
113
+ name: "baseURL",
114
+ message: "What's the base URL of your OpenAI-compatible server?",
115
+ default: env.openaiBaseURL ||
116
+ config.openaiCompatibleBaseURL ||
117
+ DEFAULT_OPENAI_BASE_URL,
118
+ validate: (v) => /^https?:\/\//.test(v) || "Must start with http:// or https://",
119
+ },
120
+ {
121
+ type: "password",
122
+ name: "apiKey",
123
+ message: "API key (leave blank if your server doesn't need one):",
124
+ mask: "*",
125
+ default: env.openaiKey || config.openaiCompatibleKey || undefined,
126
+ },
127
+ {
128
+ type: "input",
129
+ name: "model",
130
+ message: "Which model? (e.g. gpt-5, qwen3-coder:480b, deepseek-v3.1:671b)",
131
+ default: config.providerType === "openai-compatible" && config.model
132
+ ? config.model
133
+ : "",
134
+ validate: (v) => v.trim().length > 0 || "Required",
135
+ },
136
+ ]);
137
+ return {
138
+ openaiCompatibleBaseURL: baseURL,
139
+ openaiCompatibleKey: apiKey || "",
140
+ model: model.trim(),
141
+ };
142
+ }
143
+ async function promptCredentials(provider, env) {
144
+ return provider === "openai-compatible"
145
+ ? promptOpenAICompatCredentials(env)
146
+ : promptAnthropicCredentials(env);
147
+ }
148
+ async function ensureEncryptionKey() {
149
+ if (config.dbEncryptionKey) {
150
+ console.log("");
151
+ console.log(chalk.dim("Using the encryption key already on file."));
152
+ return;
153
+ }
154
+ const mode = await listPrompt({
155
+ name: "mode",
156
+ message: "Encrypt the local database? (recommended)",
157
+ choices: [
158
+ { name: "Yes (generate a strong key automatically)", value: "auto" },
159
+ { name: "Yes (I'll provide my own passphrase)", value: "manual" },
160
+ { name: "No (store plaintext)", value: "none" },
161
+ ],
162
+ default: "auto",
163
+ });
164
+ if (mode === "auto") {
165
+ saveConfig({ dbEncryptionKey: generateKey() });
166
+ console.log(chalk.dim(`Generated a new DB encryption key and saved it to ${getConfigPath()}.`));
167
+ return;
168
+ }
169
+ if (mode === "manual") {
170
+ const { key: passphrase } = await inquirer.prompt([
171
+ {
172
+ type: "password",
173
+ name: "key",
174
+ message: "Choose a passphrase (at least 8 characters):",
175
+ mask: "*",
176
+ validate: (v) => v.length >= 8 || "Use at least 8 characters.",
177
+ },
178
+ ]);
179
+ saveConfig({ dbEncryptionKey: passphrase });
180
+ }
181
+ }
182
+ function finalizeDataDir(userName) {
183
+ const dataDir = config.dataDir || resolve(getPlasalidDir(), "data");
184
+ saveConfig({ dataDir });
185
+ ensureDir(getDataDir());
186
+ createContextTemplate(userName);
187
+ return dataDir;
188
+ }
189
+ export async function runSetup() {
190
+ printBanner();
191
+ ensureDir(getPlasalidDir());
192
+ const env = readEnvDefaults();
193
+ const userName = await promptUserName(env);
194
+ const provider = await promptProviderChoice();
195
+ const credentials = await promptCredentials(provider, env);
196
+ saveConfig({
197
+ providerType: provider,
198
+ userName: userName || "User",
199
+ ...credentials,
200
+ });
201
+ await ensureEncryptionKey();
202
+ const dataDir = finalizeDataDir(userName || "User");
203
+ printSummary(dataDir);
204
+ }
205
+ export function ensureConfigured() {
206
+ if (!isConfigured()) {
207
+ console.error(chalk.red("Plasalid is not configured. Run `plasalid setup` first."));
208
+ process.exit(1);
209
+ }
210
+ }
@@ -0,0 +1,38 @@
1
+ import type { ProgressCallback } from "../ai/agent.js";
2
+ /**
3
+ * Minimal spinner interface so callers don't care whether we're animating in
4
+ * a TTY or just printing plain lines. The same instance can be `pause()`d and
5
+ * `resume()`d around an inquirer prompt to keep the terminal sane.
6
+ */
7
+ export interface SpinnerLike {
8
+ text: string;
9
+ succeed(text?: string): void;
10
+ fail(text?: string): void;
11
+ info(text?: string): void;
12
+ stop(): void;
13
+ /** TTY: clear the spinner line (ora.stop). Non-TTY: no-op. */
14
+ pause(): void;
15
+ /** TTY: resume animation with the current `text`. Non-TTY: no-op. */
16
+ resume(): void;
17
+ }
18
+ /**
19
+ * One blank line above every spinner so output doesn't crowd whatever printed
20
+ * before. TTY uses ora; non-TTY (cron, piped output) prints the leading text
21
+ * once and turns succeed/fail/info into prefixed plain lines.
22
+ */
23
+ export declare function statusSpinner(text: string): SpinnerLike;
24
+ /**
25
+ * Build an `ask_user`-style prompter bound to the active spinner. Pauses the
26
+ * spinner around the inquirer call so it doesn't fight for the same terminal
27
+ * line, pads with blank lines for readability, and always includes a free-text
28
+ * escape on choice prompts ("Type a different answer…").
29
+ */
30
+ export declare function makePromptUser(spinner: SpinnerLike): (prompt: string, options?: string[]) => Promise<string>;
31
+ /**
32
+ * Standard agent-progress → spinner-text bridge.
33
+ * - `phase: "tool"` maps the tool name through `TOOL_LABELS`.
34
+ * - `phase: "responding"` picks a stable thinking phrase per session and shows
35
+ * the elapsed time + tool count.
36
+ * Optional `subject` (e.g. a file name) is appended in parentheses.
37
+ */
38
+ export declare function makeAgentOnProgress(spinner: SpinnerLike, subject?: string): ProgressCallback;
package/dist/cli/ux.js ADDED
@@ -0,0 +1,104 @@
1
+ import inquirer from "inquirer";
2
+ import ora from "ora";
3
+ import { TOOL_LABELS } from "../ai/tools/index.js";
4
+ import { pickThinking } from "../ai/thinking.js";
5
+ import { formatDuration } from "./format.js";
6
+ /**
7
+ * One blank line above every spinner so output doesn't crowd whatever printed
8
+ * before. TTY uses ora; non-TTY (cron, piped output) prints the leading text
9
+ * once and turns succeed/fail/info into prefixed plain lines.
10
+ */
11
+ export function statusSpinner(text) {
12
+ console.log("");
13
+ if (process.stdout.isTTY) {
14
+ const spinner = ora({ text }).start();
15
+ return {
16
+ get text() { return spinner.text; },
17
+ set text(t) { spinner.text = t; },
18
+ succeed: (t) => { spinner.succeed(t); },
19
+ fail: (t) => { spinner.fail(t); },
20
+ info: (t) => { spinner.info(t); },
21
+ stop: () => { spinner.stop(); },
22
+ pause: () => { spinner.stop(); },
23
+ resume: () => { spinner.start(); },
24
+ };
25
+ }
26
+ console.log(text);
27
+ return {
28
+ text,
29
+ succeed: (t) => { if (t)
30
+ console.log(`✓ ${t}`); },
31
+ fail: (t) => { if (t)
32
+ console.log(`✗ ${t}`); },
33
+ info: (t) => { if (t)
34
+ console.log(`• ${t}`); },
35
+ stop: () => { },
36
+ pause: () => { },
37
+ resume: () => { },
38
+ };
39
+ }
40
+ /**
41
+ * Build an `ask_user`-style prompter bound to the active spinner. Pauses the
42
+ * spinner around the inquirer call so it doesn't fight for the same terminal
43
+ * line, pads with blank lines for readability, and always includes a free-text
44
+ * escape on choice prompts ("Type a different answer…").
45
+ */
46
+ export function makePromptUser(spinner) {
47
+ const OTHER = "__plasalid_other__";
48
+ return async (prompt, options) => {
49
+ spinner.pause();
50
+ console.log("");
51
+ try {
52
+ if (options && options.length > 0) {
53
+ const choices = [
54
+ // A blank-ish separator gives breathing room between the question
55
+ // line and the first choice — inquirer renders separators inline,
56
+ // and rejects truly-empty strings, so we use a single space.
57
+ new inquirer.Separator(" "),
58
+ ...options.map(o => ({ name: o, value: o })),
59
+ new inquirer.Separator(),
60
+ { name: "Type a different answer…", value: OTHER },
61
+ ];
62
+ const { choice } = await inquirer.prompt([
63
+ { type: "list", name: "choice", message: prompt, choices },
64
+ ]);
65
+ if (choice === OTHER) {
66
+ const { freeform } = await inquirer.prompt([
67
+ { type: "input", name: "freeform", message: "Your answer:" },
68
+ ]);
69
+ return String(freeform).trim();
70
+ }
71
+ return String(choice);
72
+ }
73
+ const { answer } = await inquirer.prompt([
74
+ { type: "input", name: "answer", message: prompt },
75
+ ]);
76
+ return String(answer);
77
+ }
78
+ finally {
79
+ console.log("");
80
+ spinner.resume();
81
+ }
82
+ };
83
+ }
84
+ /**
85
+ * Standard agent-progress → spinner-text bridge.
86
+ * - `phase: "tool"` maps the tool name through `TOOL_LABELS`.
87
+ * - `phase: "responding"` picks a stable thinking phrase per session and shows
88
+ * the elapsed time + tool count.
89
+ * Optional `subject` (e.g. a file name) is appended in parentheses.
90
+ */
91
+ export function makeAgentOnProgress(spinner, subject) {
92
+ const idlePhrase = pickThinking();
93
+ const subjectPart = subject ? ` (${subject})` : "";
94
+ return ({ phase, toolName, toolCount, elapsedMs }) => {
95
+ const elapsed = formatDuration(elapsedMs);
96
+ const suffix = toolCount > 0 ? ` (${toolCount} tool${toolCount === 1 ? "" : "s"}, ${elapsed})` : "";
97
+ if (phase === "tool" && toolName) {
98
+ spinner.text = `${TOOL_LABELS[toolName] ?? toolName}${suffix}`;
99
+ }
100
+ else {
101
+ spinner.text = `${idlePhrase}${subjectPart}${suffix}`;
102
+ }
103
+ };
104
+ }
@@ -0,0 +1,21 @@
1
+ import "dotenv/config";
2
+ export interface PlasalidConfig {
3
+ anthropicKey: string;
4
+ model: string;
5
+ providerType: "anthropic" | "openai-compatible";
6
+ openaiCompatibleKey: string;
7
+ openaiCompatibleBaseURL: string;
8
+ displayLocale: string;
9
+ displayCurrency: string;
10
+ dbPath: string;
11
+ dbEncryptionKey: string;
12
+ dataDir: string;
13
+ userName: string;
14
+ thinkingBudget: number;
15
+ }
16
+ export declare function getPlasalidDir(): string;
17
+ export declare function getConfigPath(): string;
18
+ export declare function getDataDir(): string;
19
+ export declare const config: PlasalidConfig;
20
+ export declare function isConfigured(): boolean;
21
+ export declare function saveConfig(partial: Partial<PlasalidConfig>): void;
package/dist/config.js ADDED
@@ -0,0 +1,66 @@
1
+ import "dotenv/config";
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "fs";
3
+ import { resolve } from "path";
4
+ import { homedir } from "os";
5
+ const PLASALID_DIR = resolve(homedir(), ".plasalid");
6
+ export function getPlasalidDir() {
7
+ return PLASALID_DIR;
8
+ }
9
+ export function getConfigPath() {
10
+ return resolve(PLASALID_DIR, "config.json");
11
+ }
12
+ export function getDataDir() {
13
+ return config.dataDir;
14
+ }
15
+ function loadFileConfig() {
16
+ const configPath = getConfigPath();
17
+ if (!existsSync(configPath))
18
+ return {};
19
+ try {
20
+ return JSON.parse(readFileSync(configPath, "utf-8"));
21
+ }
22
+ catch {
23
+ return {};
24
+ }
25
+ }
26
+ function buildConfig() {
27
+ const file = loadFileConfig();
28
+ // Precedence: env > file > default. Env is checked first so a shell-exported
29
+ // override always wins over whatever is in ~/.plasalid/config.json.
30
+ return {
31
+ anthropicKey: process.env.ANTHROPIC_API_KEY || file.anthropicKey || "",
32
+ model: process.env.PLASALID_MODEL || file.model || "claude-sonnet-4-6",
33
+ providerType: process.env.PLASALID_PROVIDER ||
34
+ file.providerType ||
35
+ "anthropic",
36
+ openaiCompatibleKey: process.env.OPENAI_COMPATIBLE_API_KEY || file.openaiCompatibleKey || "",
37
+ openaiCompatibleBaseURL: process.env.OPENAI_COMPATIBLE_BASE_URL || file.openaiCompatibleBaseURL || "",
38
+ displayLocale: file.displayLocale || "th-TH",
39
+ displayCurrency: file.displayCurrency || "THB",
40
+ dbPath: process.env.PLASALID_DB_PATH || file.dbPath || resolve(PLASALID_DIR, "db.sqlite"),
41
+ dbEncryptionKey: process.env.PLASALID_DB_ENCRYPTION_KEY || file.dbEncryptionKey || "",
42
+ dataDir: process.env.PLASALID_DATA_DIR || file.dataDir || resolve(PLASALID_DIR, "data"),
43
+ userName: file.userName || "User",
44
+ thinkingBudget: file.thinkingBudget ?? 8000,
45
+ };
46
+ }
47
+ export const config = buildConfig();
48
+ export function isConfigured() {
49
+ if (config.providerType === "openai-compatible") {
50
+ return !!config.openaiCompatibleBaseURL;
51
+ }
52
+ return !!config.anthropicKey;
53
+ }
54
+ export function saveConfig(partial) {
55
+ const configPath = getConfigPath();
56
+ if (!existsSync(PLASALID_DIR))
57
+ mkdirSync(PLASALID_DIR, { recursive: true });
58
+ const existing = loadFileConfig();
59
+ const merged = { ...existing, ...partial };
60
+ writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n", { mode: 0o600 });
61
+ try {
62
+ chmodSync(configPath, 0o600);
63
+ }
64
+ catch { }
65
+ Object.assign(config, merged);
66
+ }
@@ -0,0 +1,6 @@
1
+ export declare function getDisplayLocale(): string;
2
+ export declare function getDisplayCurrency(): string;
3
+ export declare function formatCurrencyAmount(amount: number, options?: {
4
+ minimumFractionDigits?: number;
5
+ maximumFractionDigits?: number;
6
+ }): string;
@@ -0,0 +1,19 @@
1
+ import { config } from "./config.js";
2
+ const DEFAULT_LOCALE = "th-TH";
3
+ const DEFAULT_CURRENCY = "THB";
4
+ export function getDisplayLocale() {
5
+ return config.displayLocale || DEFAULT_LOCALE;
6
+ }
7
+ export function getDisplayCurrency() {
8
+ return config.displayCurrency || DEFAULT_CURRENCY;
9
+ }
10
+ export function formatCurrencyAmount(amount, options = {}) {
11
+ const locale = getDisplayLocale();
12
+ const currency = getDisplayCurrency();
13
+ return new Intl.NumberFormat(locale, {
14
+ style: "currency",
15
+ currency,
16
+ minimumFractionDigits: options.minimumFractionDigits,
17
+ maximumFractionDigits: options.maximumFractionDigits,
18
+ }).format(Math.abs(amount));
19
+ }
@@ -0,0 +1,5 @@
1
+ import Database from "libsql";
2
+ /** Get the single DB instance */
3
+ export declare function getDb(): Database.Database;
4
+ /** Close all connections (for graceful shutdown) */
5
+ export declare function closeAll(): void;
@@ -0,0 +1,45 @@
1
+ import Database from "libsql";
2
+ import { config } from "../config.js";
3
+ import { migrate } from "./schema.js";
4
+ import { dirname } from "path";
5
+ import { mkdirSync, existsSync, chmodSync } from "fs";
6
+ let singleDb = null;
7
+ function openDb(dbPath, encryptionKey) {
8
+ const dir = dirname(dbPath);
9
+ if (!existsSync(dir))
10
+ mkdirSync(dir, { recursive: true });
11
+ const opts = {};
12
+ if (encryptionKey) {
13
+ opts.encryptionCipher = "aes256cbc";
14
+ opts.encryptionKey = encryptionKey;
15
+ }
16
+ const db = new Database(dbPath, opts);
17
+ // Verify the database is accessible
18
+ try {
19
+ db.pragma("journal_mode = WAL");
20
+ }
21
+ catch (err) {
22
+ db.close();
23
+ throw new Error("Failed to open database. Wrong encryption key or corrupt database file. " +
24
+ "If you changed your encryption key, restore from backup or delete ~/.plasalid/db.sqlite to start fresh.");
25
+ }
26
+ db.pragma("foreign_keys = ON");
27
+ migrate(db);
28
+ try {
29
+ chmodSync(dbPath, 0o600);
30
+ }
31
+ catch { }
32
+ return db;
33
+ }
34
+ /** Get the single DB instance */
35
+ export function getDb() {
36
+ if (!singleDb) {
37
+ singleDb = openDb(config.dbPath, config.dbEncryptionKey || undefined);
38
+ }
39
+ return singleDb;
40
+ }
41
+ /** Close all connections (for graceful shutdown) */
42
+ export function closeAll() {
43
+ singleDb?.close();
44
+ singleDb = null;
45
+ }
@@ -0,0 +1,11 @@
1
+ /** Generate a 32-byte hex string suitable for use as a libsql encryption key. */
2
+ export declare function generateKey(): string;
3
+ /**
4
+ * Encrypt a secret (e.g. PDF password) at the application layer using AES-256-GCM.
5
+ * Key is derived from the DB encryption key via scrypt. When `dbKey` is empty
6
+ * (user opted out of DB encryption), this is a passthrough.
7
+ *
8
+ * Output format: `gcm:<iv-hex>:<tag-hex>:<ciphertext-hex>`
9
+ */
10
+ export declare function encryptSecret(plaintext: string, dbKey: string): string;
11
+ export declare function decryptSecret(ciphertext: string, dbKey: string): string;
@@ -0,0 +1,45 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
2
+ const SECRET_KEY_SALT = "plasalid-secret-v1";
3
+ const FORMAT_PREFIX = "gcm:";
4
+ /** Generate a 32-byte hex string suitable for use as a libsql encryption key. */
5
+ export function generateKey() {
6
+ return randomBytes(32).toString("hex");
7
+ }
8
+ function deriveSecretKey(dbKey) {
9
+ return scryptSync(dbKey, SECRET_KEY_SALT, 32);
10
+ }
11
+ /**
12
+ * Encrypt a secret (e.g. PDF password) at the application layer using AES-256-GCM.
13
+ * Key is derived from the DB encryption key via scrypt. When `dbKey` is empty
14
+ * (user opted out of DB encryption), this is a passthrough.
15
+ *
16
+ * Output format: `gcm:<iv-hex>:<tag-hex>:<ciphertext-hex>`
17
+ */
18
+ export function encryptSecret(plaintext, dbKey) {
19
+ if (!dbKey)
20
+ return plaintext;
21
+ const key = deriveSecretKey(dbKey);
22
+ const iv = randomBytes(12);
23
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
24
+ const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
25
+ const tag = cipher.getAuthTag();
26
+ return `${FORMAT_PREFIX}${iv.toString("hex")}:${tag.toString("hex")}:${ct.toString("hex")}`;
27
+ }
28
+ export function decryptSecret(ciphertext, dbKey) {
29
+ if (!dbKey || !ciphertext.startsWith(FORMAT_PREFIX))
30
+ return ciphertext;
31
+ const rest = ciphertext.slice(FORMAT_PREFIX.length);
32
+ const parts = rest.split(":");
33
+ if (parts.length !== 3) {
34
+ throw new Error("Malformed encrypted secret.");
35
+ }
36
+ const [ivHex, tagHex, ctHex] = parts;
37
+ const key = deriveSecretKey(dbKey);
38
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivHex, "hex"));
39
+ decipher.setAuthTag(Buffer.from(tagHex, "hex"));
40
+ const pt = Buffer.concat([
41
+ decipher.update(Buffer.from(ctHex, "hex")),
42
+ decipher.final(),
43
+ ]);
44
+ return pt.toString("utf8");
45
+ }
@@ -0,0 +1,16 @@
1
+ export declare function resolvePeriod(period: string): {
2
+ start: string;
3
+ end: string;
4
+ };
5
+ export declare function shiftDays(date: Date, days: number): Date;
6
+ export declare function simulatePayoff(balance: number, annualRate: number, monthlyPayment: number): {
7
+ months: number;
8
+ totalInterest: number;
9
+ schedule: {
10
+ month: number;
11
+ payment: number;
12
+ principal: number;
13
+ interest: number;
14
+ remaining: number;
15
+ }[];
16
+ };
@@ -0,0 +1,45 @@
1
+ export function resolvePeriod(period) {
2
+ const now = new Date();
3
+ const y = now.getFullYear();
4
+ const m = now.getMonth();
5
+ switch (period) {
6
+ case "this_month":
7
+ return { start: new Date(y, m, 1).toISOString().slice(0, 10), end: now.toISOString().slice(0, 10) };
8
+ case "last_month":
9
+ return { start: new Date(y, m - 1, 1).toISOString().slice(0, 10), end: new Date(y, m, 0).toISOString().slice(0, 10) };
10
+ case "this_year":
11
+ return { start: new Date(y, 0, 1).toISOString().slice(0, 10), end: now.toISOString().slice(0, 10) };
12
+ case "last_30":
13
+ return { start: shiftDays(now, -30).toISOString().slice(0, 10), end: now.toISOString().slice(0, 10) };
14
+ case "last_90":
15
+ return { start: shiftDays(now, -90).toISOString().slice(0, 10), end: now.toISOString().slice(0, 10) };
16
+ default: {
17
+ const parts = period.split(":");
18
+ if (parts.length === 2)
19
+ return { start: parts[0], end: parts[1] };
20
+ throw new Error(`Unknown period: ${period}. Use this_month, last_month, this_year, last_30, last_90, or START:END`);
21
+ }
22
+ }
23
+ }
24
+ export function shiftDays(date, days) {
25
+ const d = new Date(date);
26
+ d.setDate(d.getDate() + days);
27
+ return d;
28
+ }
29
+ export function simulatePayoff(balance, annualRate, monthlyPayment) {
30
+ const monthlyRate = annualRate / 100 / 12;
31
+ let remaining = balance;
32
+ let totalInterest = 0;
33
+ const schedule = [];
34
+ let month = 0;
35
+ while (remaining > 0.01 && month < 600) {
36
+ month++;
37
+ const interest = remaining * monthlyRate;
38
+ const payment = Math.min(monthlyPayment, remaining + interest);
39
+ const principal = payment - interest;
40
+ remaining -= principal;
41
+ totalInterest += interest;
42
+ schedule.push({ month, payment: Math.round(payment * 100) / 100, principal: Math.round(principal * 100) / 100, interest: Math.round(interest * 100) / 100, remaining: Math.round(Math.max(0, remaining) * 100) / 100 });
43
+ }
44
+ return { months: month, totalInterest: Math.round(totalInterest * 100) / 100, schedule };
45
+ }