plasalid 0.7.2 → 0.7.4

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 (39) hide show
  1. package/README.md +14 -14
  2. package/dist/accounts/taxonomy.d.ts +1 -1
  3. package/dist/accounts/taxonomy.js +1 -1
  4. package/dist/ai/agent.d.ts +5 -5
  5. package/dist/ai/agent.js +6 -6
  6. package/dist/ai/personas.d.ts +1 -1
  7. package/dist/ai/personas.js +14 -14
  8. package/dist/ai/prompt-sections.d.ts +4 -4
  9. package/dist/ai/prompt-sections.js +1 -1
  10. package/dist/ai/system-prompt.d.ts +2 -2
  11. package/dist/ai/system-prompt.js +4 -4
  12. package/dist/ai/tools/clarify.d.ts +2 -0
  13. package/dist/ai/tools/clarify.js +169 -0
  14. package/dist/ai/tools/index.js +7 -7
  15. package/dist/ai/tools/ingest.d.ts +1 -1
  16. package/dist/ai/tools/ingest.js +8 -8
  17. package/dist/ai/tools/read.js +1 -1
  18. package/dist/ai/tools/record.js +5 -5
  19. package/dist/ai/tools/types.d.ts +2 -2
  20. package/dist/cli/commands/clarify.d.ts +5 -0
  21. package/dist/cli/commands/clarify.js +44 -0
  22. package/dist/cli/commands/rules.js +1 -1
  23. package/dist/cli/commands/scan.js +9 -9
  24. package/dist/cli/commands/status.js +1 -1
  25. package/dist/cli/index.js +6 -6
  26. package/dist/cli/ink/ScanDashboard.d.ts +1 -1
  27. package/dist/cli/ink/ScanDashboard.js +2 -2
  28. package/dist/cli/setup.js +1 -1
  29. package/dist/cli/ux.js +1 -1
  30. package/dist/scanner/clarifier-memory.d.ts +8 -0
  31. package/dist/scanner/clarifier-memory.js +24 -0
  32. package/dist/scanner/clarifier.d.ts +39 -0
  33. package/dist/scanner/clarifier.js +196 -0
  34. package/dist/scanner/engine.d.ts +3 -3
  35. package/dist/scanner/engine.js +8 -8
  36. package/dist/scanner/hooks.d.ts +3 -3
  37. package/dist/scanner/worker.d.ts +1 -1
  38. package/dist/scanner/worker.js +1 -1
  39. package/package.json +1 -1
package/README.md CHANGED
@@ -15,15 +15,17 @@
15
15
 
16
16
  <br />
17
17
 
18
- In the US/EU, a financial data aggregator like Plaid empowers most finance apps: one connection, and every app sees the same unified view of your accounts. Most of the world doesn't have that, including Thailand, where there's no such aggregator platform. All bank data is siloed: to know where your financial status stands means logging into five bank apps one by one. Creating a unified view of personal financial data is very challenging.
18
+ In US and Europe, the most of financial apps is likely powered by a hidden aggregators engine like Plaid. You can link your bank accounts once and see your entire financial life in one place. But for most of the world, Thailand included, that infrastructure simply does not exist.
19
19
 
20
- That's why Plasalid emerged to resolve this pain point. Your data has stayed fragmented for decades, with no way to bring it together. You can't manage a mortgage effectively without the full picture, and you may be completely blind to your recurring monthly income and expenses. Subscriptions stay active long after they're forgotten, unknown charges go unverified, bank accounts opened years ago drift unchecked, and unexpected spending may silently grow beyond what any single statement shows. When your finances are hard to manage, your life definitely becomes more difficult. Your plans toward financial stability or freedom slip further out of reach. Plasalid is built to solve this.
20
+ Your data is locked in bank silos. Tracking your net worth means logging into half a dozen apps and crunching the numbers manually. This fragmentation creates massive blind spots. Subscriptions are forgotten, strange charges go unnoticed, and planning for big financial goals becomes a guessing game.
21
21
 
22
- Plasalid addresses this with a simple founding concept: let users drop all their financial documents - bank statements, credit-card statements, payslips, brokerage statements - onto their own machine, where Plasalid leverages AI to extract every transaction, balance, and holding into a single, structured, double-entry database that serves as context for future processing.
22
+ Plasalid is a local data harness built to fix this. Think of it as a personal financial harness.
23
23
 
24
- Moreover, Plasalid comes with a built-in agentic chat that queries the data directly, so questions like which subscriptions are still active, where money went last month, or what your current net worth is can be answered against actual records rather than estimates. You can talk with your money on Plasalid to help you understand your financial situation and plan efficiently.
24
+ You drop your raw financial documents (bank statements, credit card bills, payslips) straight into a folder on your machine. Plasalid parses those files and extracts every transaction, balance, and holding. It transforms a messy pile of PDFs into a clean, double-entry ledger. You only have to build this foundation once. The result is an open, structured backend for your finances, ready to plug into any tool you want.
25
25
 
26
- The data ledger also serves as a harness, open to any AI agent that connects to it, so the picture you assemble once is reusable across whatever tools you choose to use.
26
+ To show you the power of this harness out of the box, Plasalid includes a built-in AI agent. Because your ledger is fully structured, you can actually talk to your money. Ask a question like "Which subscriptions are still active?" or "What did I spend on food last month?". You get exact numbers pulled directly from your records, not estimates or AI hallucinations.
27
+
28
+ We also built strict boundaries around your privacy. The database is encrypted locally. Plasalid automatically strips out all PII before sending data to an external API. This mean if you swap in a local AI model, your setup runs can stay 100% private and offline.
27
29
 
28
30
  <p align="center">
29
31
  <img src=".github/plasalid-demo.png" alt="demo" width="100%" />
@@ -67,15 +69,14 @@ Then:
67
69
 
68
70
  1. Run `plasalid open` to pop open your data folder in Finder/Explorer, then drag in any bank or credit-card statement PDF you've got. **One file is enough to start** — Plasalid will already give you useful answers about that account. More files make the picture richer.
69
71
  2. Run `plasalid scan` — it parses your PDFs end-to-end without stopping.
70
- 3. Run `plasalid resolve` to connect related transactions, learn your recurring rhythms, and clear up anything the scanner flagged as a unknown.
72
+ 3. Run `plasalid clarify` to connect related transactions, learn your recurring rhythms, and clear up anything the scanner flagged as a question.
71
73
  4. Run `plasalid` to chat with what was scanned.
72
74
 
73
75
  Other day-to-day commands:
74
76
 
75
77
  - `plasalid scan <regex>` — only scan files whose path matches the regex.
76
78
  - `plasalid scan <regex> --force` — re-scan matching files (replaces prior records).
77
- - `plasalid resolve` — walk every open unknown one at a time and apply your decision (categorize, merge duplicates, link recurrences, skip). Filter with `--account`, `--from`, `--to`, or `--kind`.
78
- - `plasalid revert <regex>` — delete scanned files matching the regex and every transaction derived from them.
79
+ - `plasalid clarify` — walk every open question one at a time and apply your decision (categorize, merge duplicates, link recurrences, etc).
79
80
 
80
81
  ## Commands
81
82
 
@@ -90,8 +91,7 @@ plasalid transactions # List transactions and their postings (filt
90
91
  plasalid status # Net worth and this-month income/expense totals
91
92
  plasalid record [utterance] # Add a manual transaction, account, balance, or merchant from a plain-language line
92
93
  plasalid scan [regex] [--force] # Scan new PDFs; --force cascade-deletes prior records before re-scanning
93
- plasalid revert [regex] # Delete scanned files matching <regex> and their transactions
94
- plasalid resolve # Walk every open unknown and apply your decision (--account, --from, --to, --kind also accepted)
94
+ plasalid clarify # Walk every open question and apply your decision
95
95
  ```
96
96
 
97
97
  ## How It Works
@@ -110,7 +110,7 @@ plasalid resolve # Walk every open unknown and apply your dec
110
110
  AI provider (PII-redacted)
111
111
 
112
112
  ┌──────────▼──────────┐
113
- │ Encrypted DB │◀──── plasalid resolve
113
+ │ Encrypted DB │◀──── plasalid clarify
114
114
  └──────────┬──────────┘
115
115
 
116
116
  plasalid
@@ -139,15 +139,15 @@ Plasalid stores everything in `~/.plasalid/`:
139
139
  data/ # Drop any PDFs here (subfolders allowed; AI classifies)
140
140
  ```
141
141
 
142
- `db.sqlite` holds the three-layer ledger (hierarchical accounts, deduplicated merchants with learned default categories, transactions and postings), scan history, open unknowns awaiting resolve, recurring transactions (Spotify, salary, rent — recognized during resolve and linked from each member transaction), an action log for record-mode audit, persisted long-term memories, and AES-GCM-encrypted PDF passwords keyed by filename pattern. Everything is wrapped in libsql's AES-256 page encryption.
142
+ `db.sqlite` holds the three-layer ledger (hierarchical accounts, deduplicated merchants with categories, transactions and postings), scan history, open questions awaiting clarify, recurring transactions (Spotify, salary, rent — recognized during clarify transaction), persisted long-term memories, and AES-GCM-encrypted passwords. Everything is wrapped in libsql's AES-256 page encryption.
143
143
 
144
144
  ### Environment Variables
145
145
 
146
146
  ```bash
147
147
  ANTHROPIC_API_KEY= # Anthropic API key (required when provider is anthropic)
148
148
  PLASALID_MODEL= # Model name; default for Anthropic: claude-sonnet-4-6
149
- PLASALID_PROVIDER= # anthropic | openai-compatible. Default: anthropic
150
- OPENAI_COMPATIBLE_BASE_URL= # e.g. http://localhost:11434/v1 (Ollama)
149
+ PLASALID_PROVIDER= # anthropic | openai-compatible. default: anthropic
150
+ OPENAI_COMPATIBLE_BASE_URL= # e.g. http://localhost:11434/v1 (ollama)
151
151
  OPENAI_COMPATIBLE_API_KEY= # API key for the OpenAI-compatible server (often unused)
152
152
  PLASALID_DB_ENCRYPTION_KEY= # DB encryption passphrase
153
153
  PLASALID_DB_PATH= # Default: ~/.plasalid/db.sqlite
@@ -1,7 +1,7 @@
1
1
  export type AccountType = "asset" | "liability" | "income" | "expense" | "equity";
2
2
  export declare const ACCOUNT_TYPE_DESCRIPTIONS: Record<AccountType, string>;
3
3
  /**
4
- * Stringified Thai taxonomy block for the scan/resolve system prompts.
4
+ * Stringified Thai taxonomy block for the scan/clarify system prompts.
5
5
  * Lists known Thai institutions and suggested subtypes so the model picks
6
6
  * consistent `bank_name` and `subtype` values across statements.
7
7
  */
@@ -169,7 +169,7 @@ const SUGGESTED_INCOME_SUBTYPES = [
169
169
  "other",
170
170
  ];
171
171
  /**
172
- * Stringified Thai taxonomy block for the scan/resolve system prompts.
172
+ * Stringified Thai taxonomy block for the scan/clarify system prompts.
173
173
  * Lists known Thai institutions and suggested subtypes so the model picks
174
174
  * consistent `bank_name` and `subtype` values across statements.
175
175
  */
@@ -1,5 +1,5 @@
1
1
  import type Database from "libsql";
2
- import { type ScanPromptOptions, type ResolvePromptOptions, type RecordPromptOptions } from "./system-prompt.js";
2
+ import { type ScanPromptOptions, type ClarifyPromptOptions, type RecordPromptOptions } from "./system-prompt.js";
3
3
  import { type AgentExecutionContext } from "./tools/index.js";
4
4
  import type { NormalizedMessage } from "./provider.js";
5
5
  export { AbortedError } from "./errors.js";
@@ -18,7 +18,7 @@ export declare function handleChatMessage(db: Database.Database, userMessage: st
18
18
  * Scan-time agent loop. Caller supplies the initial user message (which carries
19
19
  * the PDF as a content block) and a AgentExecutionContext that scopes the file
20
20
  * id, scanId, and progress sink. A truncated run records a scan_truncated
21
- * question so resolve can surface it later.
21
+ * question so clarify can surface it later.
22
22
  */
23
23
  export declare function runScanAgent(opts: {
24
24
  db: Database.Database;
@@ -41,14 +41,14 @@ export declare function runRecordAgent(opts: {
41
41
  signal?: AbortSignal;
42
42
  }): Promise<string>;
43
43
  /**
44
- * Resolve-time agent loop. Driven by RESOLVE_PERSONA. Surveys every open
44
+ * Clarify-time agent loop. Driven by CLARIFY_PERSONA. Surveys every open
45
45
  * question, applies memory/heuristic resolutions silently, groups whatever
46
46
  * remains and asks the user once per group via ask_user.
47
47
  */
48
- export declare function runResolveAgent(opts: {
48
+ export declare function runClarifyAgent(opts: {
49
49
  db: Database.Database;
50
50
  initialMessages: NormalizedMessage[];
51
- prompt: ResolvePromptOptions;
51
+ prompt: ClarifyPromptOptions;
52
52
  agentCtx: AgentExecutionContext;
53
53
  onProgress?: ProgressCallback;
54
54
  signal?: AbortSignal;
package/dist/ai/agent.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { config } from "../config.js";
2
- import { buildChatSystemPrompt, buildScanSystemPrompt, buildResolveSystemPrompt, buildRecordSystemPrompt, } from "./system-prompt.js";
2
+ import { buildChatSystemPrompt, buildScanSystemPrompt, buildClarifySystemPrompt, buildRecordSystemPrompt, } from "./system-prompt.js";
3
3
  import { getToolDefinitions, executeTool } from "./tools/index.js";
4
4
  import { getConversationHistory, saveMessage } from "./memory.js";
5
5
  import { recordQuestion } from "../db/queries/questions.js";
@@ -125,7 +125,7 @@ export async function handleChatMessage(db, userMessage, onProgress, signal) {
125
125
  * Scan-time agent loop. Caller supplies the initial user message (which carries
126
126
  * the PDF as a content block) and a AgentExecutionContext that scopes the file
127
127
  * id, scanId, and progress sink. A truncated run records a scan_truncated
128
- * question so resolve can surface it later.
128
+ * question so clarify can surface it later.
129
129
  */
130
130
  export async function runScanAgent(opts) {
131
131
  const systemPrompt = redact(buildScanSystemPrompt(opts.db, opts.prompt));
@@ -173,16 +173,16 @@ export async function runRecordAgent(opts) {
173
173
  return text;
174
174
  }
175
175
  /**
176
- * Resolve-time agent loop. Driven by RESOLVE_PERSONA. Surveys every open
176
+ * Clarify-time agent loop. Driven by CLARIFY_PERSONA. Surveys every open
177
177
  * question, applies memory/heuristic resolutions silently, groups whatever
178
178
  * remains and asks the user once per group via ask_user.
179
179
  */
180
- export async function runResolveAgent(opts) {
181
- const systemPrompt = redact(buildResolveSystemPrompt(opts.db, opts.prompt));
180
+ export async function runClarifyAgent(opts) {
181
+ const systemPrompt = redact(buildClarifySystemPrompt(opts.db, opts.prompt));
182
182
  const { text } = await runAgent({
183
183
  db: opts.db,
184
184
  systemPrompt,
185
- tools: getToolDefinitions("resolve"),
185
+ tools: getToolDefinitions("clarify"),
186
186
  initialMessages: opts.initialMessages,
187
187
  agentCtx: opts.agentCtx,
188
188
  onProgress: opts.onProgress,
@@ -8,4 +8,4 @@
8
8
  export declare function chatPersona(name: string): string;
9
9
  export declare const SCAN_PERSONA: string;
10
10
  export declare const RECORD_PERSONA: string;
11
- export declare const RESOLVE_PERSONA: string;
11
+ export declare const CLARIFY_PERSONA: string;
@@ -55,7 +55,7 @@ Rules:
55
55
  6. **Merchants are first-class.** Every transaction with an external counter-party (a charge to a store, a payment to a service, a refund from a vendor) must include a \`merchant\` block:
56
56
  - \`canonical_name\`: Title-cased name (e.g. \`"Starbucks"\`, \`"Amazon"\`, \`"Spotify"\`). Normalize across descriptor variations — \`"STARBUCKS #1234 BKK"\`, \`"Starbucks #5678 BANGKOK"\`, \`"SBUX TH"\` all share \`"Starbucks"\`.
57
57
  - \`alias\`: the exact raw statement descriptor. Plasalid normalizes and dedups it.
58
- - \`default_account_id\`: **do not** set this on first sight, even when you're confident. The merchant's stored default is a user-taught rule, not an LLM hunch — it's only written when the resolver applies a user answer (via \`set_merchant_default_account\`) or when the user states a rule directly in record mode. Leave \`default_account_id\` unset (omit the field) on every fresh merchant block. You may still post the current row to your best-guess expense account; just don't teach the merchant that mapping system-wide.
58
+ - \`default_account_id\`: **do not** set this on first sight, even when you're confident. The merchant's stored default is a user-taught rule, not an LLM hunch — it's only written when the clarifier applies a user answer (via \`set_merchant_default_account\`) or when the user states a rule directly in record mode. Leave \`default_account_id\` unset (omit the field) on every fresh merchant block. You may still post the current row to your best-guess expense account; just don't teach the merchant that mapping system-wide.
59
59
  Also set \`raw_descriptor\` on the transaction to the exact statement line for downstream lookups.
60
60
  For transfers between own accounts and pure balance movements, omit the merchant block.
61
61
  7. **Pre-resolved merchants.** If the prompt context shows a merchant already known for the descriptor, use the supplied \`merchant_id\` and \`default_account_id\` instead of proposing a fresh merchant block. You may override the default expense account when the row's context says otherwise (e.g. a Starbucks gift-card top-up is not Dining).
@@ -63,7 +63,7 @@ Rules:
63
63
 
64
64
  Reserve \`expense:uncategorized\` + \`note_question\` with \`kind="uncategorized_expense"\` for the genuinely uncategorizable: opaque descriptors like \`PAYMENT 0042\`, \`POS 12345\`, \`BANK FEE\`, \`ATM WITHDRAWAL ID 99\`, or rows where you'd be picking randomly between three or more equally plausible categories. If the descriptor is even mildly suggestive — a recognizable brand, a transliterated Thai merchant name, a service tier (\`SUBSCRIPTION\`, \`INSURANCE PREMIUM\`) — guess.
65
65
 
66
- **Income stays strict.** For an income credit where the subtype (salary, bonus, freelance, interest, dividend, refund) isn't obvious, post to \`income:uncategorized\` (auto-created) and call \`note_question\` with \`kind="uncategorized"\` and the \`transaction_id\`. Do not pick \`income:other\` or any subtype as a guess. Income misclassifications affect tax and reporting more than expense ones do; don't guess here. The resolver batches uncategorized rows into one cleanup pass and learns the merchant's default from the user's fix.
66
+ **Income stays strict.** For an income credit where the subtype (salary, bonus, freelance, interest, dividend, refund) isn't obvious, post to \`income:uncategorized\` (auto-created) and call \`note_question\` with \`kind="uncategorized"\` and the \`transaction_id\`. Do not pick \`income:other\` or any subtype as a guess. Income misclassifications affect tax and reporting more than expense ones do; don't guess here. The clarifier batches uncategorized rows into one cleanup pass and learns the merchant's default from the user's fix.
67
67
  9. Dates: convert Buddhist Era → Gregorian by subtracting 543 from the year. Store as YYYY-MM-DD.
68
68
  10. Default currency is THB. Tag every posting with its ISO 4217 currency code; only deviate from THB when the row explicitly shows another currency (foreign-card purchases, FX transfers, multi-currency wallets).
69
69
  11. Account numbers: store only the last 4 digits (mask the rest with bullets, e.g. \`••1234\`). Never persist the full account number.
@@ -74,7 +74,7 @@ Rules:
74
74
  - **Category uncertainty alone is NOT a reason to flag.** Pick the best expense category and move on (per rule 8). Only fall back to \`expense:uncategorized\` + \`note_question\` when the descriptor is truly opaque.
75
75
  - If a row is *unparseable* (amount unreadable, date missing entirely, can't tell what account is involved), **skip the row entirely** — do not post a placeholder. Call \`note_question\` with the raw row text and no \`transaction_id\`. A missing row is better than a wrong row.
76
76
  - If you have a question about an **account itself** — the statement's bank name disagrees with the stored account, the currency disagrees, the statement_day/due_day on the statement conflicts with what's stored, or you suspect the account you're about to \`create_account\` duplicates an existing one but can't be sure — call \`note_question\` with \`account_id\` set. You can combine \`account_id\` and \`transaction_id\` if a single row triggered the doubt.
77
- - The resolver will work through questions later with the full picture across statements.
77
+ - The clarifier will work through questions later with the full picture across statements.
78
78
  - **Apply what you've already been told.** Before flagging a question, scan the "Rules you've already learned" section below. If a saved rule classifies the row — a merchant→category mapping, an account identity, a recurring-charge identity — apply it silently and do **not** raise a question. Only flag a question when the row genuinely doesn't fit any saved rule. Asking the user about something they've already told us is bad UX.
79
79
  15. When the file is fully processed, call \`mark_file_scanned\` with a short summary.
80
80
 
@@ -85,8 +85,8 @@ Common Thai statement patterns to expect:
85
85
  - Transfer slips (PromptPay / mobile banking) show source account, destination account, amount, and a reference number.
86
86
 
87
87
  How to phrase note_question:
88
- - Write a complete sentence with enough context for a later resolver who doesn't have the PDF open: include the date, the amount (formatted as ฿N,NNN.NN), and the row's description.
89
- - Never reference accounts or transactions by internal id (\`asset:…\`, \`tx:…\`) in the prompt text. Use the human account name (e.g. "KBank Savings ••8745"). The structured \`transaction_id\` and \`account_id\` arguments are fine — those are for the resolver to join on.
88
+ - Write a complete sentence with enough context for a later clarifier who doesn't have the PDF open: include the date, the amount (formatted as ฿N,NNN.NN), and the row's description.
89
+ - Never reference accounts or transactions by internal id (\`asset:…\`, \`tx:…\`) in the prompt text. Use the human account name (e.g. "KBank Savings ••8745"). The structured \`transaction_id\` and \`account_id\` arguments are fine — those are for the clarifier to join on.
90
90
  - Provide \`options\` when the resolution is a small finite choice (e.g. which category to use, debit vs credit). When you do, always include "Skip — leave as is" as one of them.
91
91
 
92
92
  Output formatting: use plain ASCII numbers (\`1.\`, \`2.\`, \`3.\`) for any lists. Never use Unicode circled digits (①②③). Never use emoji of any kind (no check marks, crosses, warning signs, colored circles, faces, hands, etc.) — use plain words.`;
@@ -100,8 +100,8 @@ Mission flow:
100
100
  Account resolution rules:
101
101
  1. When the utterance names an account ("my ttb saving", "SET portfolio", "SCB"), call find_similar_accounts(query=<that phrase>) BEFORE create_account.
102
102
  2. If find_similar_accounts returns nothing matching, you may create_account. Pick a stable colon-path id format: \`<type>:<bank>-<subtype>-<last4>\` for institution accounts (e.g. \`asset:diem-investment\`, \`liability:scb-mortgage\`), or \`<type>:<category>\` / \`<type>:<category>:<sub>\` for income/expense categories. Use list_accounts to confirm the new id is free. Always pass \`parent_id\` — for a top-level type root, parent_id=null and id must equal the type; for everything else, parent_id is the prefix before the final ':'.
103
- 3. If find_similar_accounts returns one match with similarity >= 0.7 and the name isn't an exact id hit, call clarify with options=["Yes — same account", "No — create a new one"] before deciding. Never silently pick a fuzzy match.
104
- 4. If find_similar_accounts returns multiple matches >= 0.7 (e.g. user said "my saving" and there are two saving accounts), call clarify with each candidate as an option.
103
+ 3. If find_similar_accounts returns one match with similarity >= 0.7 and the name isn't an exact id hit, call confirm with options=["Yes — same account", "No — create a new one"] before deciding. Never silently pick a fuzzy match.
104
+ 4. If find_similar_accounts returns multiple matches >= 0.7 (e.g. user said "my saving" and there are two saving accounts), call confirm with each candidate as an option.
105
105
 
106
106
  Action dispatch:
107
107
  - A transaction utterance ("buy / pay / spend / received / paid / got X") → \`record_transaction\` with the correct debit/credit sides:
@@ -120,7 +120,7 @@ Action dispatch:
120
120
  - DELETE ("delete my old empty cash account", "remove asset:old-savings") → resolve the account via find_similar_accounts, then delete_account. delete_account refuses if the account still has postings — tell the user to merge or recategorize first.
121
121
  - ACCOUNT-ONLY create ("create a new investment at Diem", "open a savings at SCB") → resolve any duplicate via find_similar_accounts first, then create_account. No transaction, no balance.
122
122
  - MERCHANT teaching ("Starbucks is Dining", "mark Lazada as Shopping") → find_or_create_merchant with the canonical name and default_account_id. No transaction.
123
- - "Pay all <category>" (e.g. "pay all credit card debt from X"): list_accounts filtered by type, get_account_balance for each, build the plan, call clarify with a one-line summary ("Settle 3 cards totaling ฿38,500 from SCB Savings — proceed?") before any record_transaction. Then post one transaction per liability.
123
+ - "Pay all <category>" (e.g. "pay all credit card debt from X"): list_accounts filtered by type, get_account_balance for each, build the plan, call confirm with a one-line summary ("Settle 3 cards totaling ฿38,500 from SCB Savings — proceed?") before any record_transaction. Then post one transaction per liability.
124
124
 
125
125
  Currency: default THB. Only deviate when the utterance explicitly names a different currency (e.g. "100 USD from ...").
126
126
 
@@ -132,7 +132,7 @@ Date: default to today (the date shown in the system prompt). Honor an explicit
132
132
 
133
133
  Learn as you go: when the utterance reveals a generalizable rule the system would benefit from on the next scan or record (a recurring payment identity, a merchant→category mapping, an account purpose, a stated preference), call save_memory with a reusable phrasing — category "general" for facts/rules, "preference" for stated preferences. Skip if a matching rule already appears in the "Rules you've already learned" block.
134
134
 
135
- When you must ask clarify (use sparingly — every question costs the user a beat):
135
+ When you must confirm (use sparingly — every question costs the user a beat):
136
136
  - Ambiguous accounts (above).
137
137
  - Missing amount in a transaction utterance.
138
138
  - Missing destination/source in a "pay X" utterance (e.g. "pay 500 for coffee" without saying which account).
@@ -141,11 +141,11 @@ When you must ask clarify (use sparingly — every question costs the user a bea
141
141
 
142
142
  Output rules:
143
143
  - After every action finishes, reply with a single short sentence summarizing what landed ("Posted ฿100 coffee expense from TTB Savings.", "Adjusted SET Portfolio: ฿1,500,000 → ฿1,800,000.").
144
- - Reply in the dominant language of the user's utterance. The same rule applies to clarify prompts you generate.
144
+ - Reply in the dominant language of the user's utterance. The same rule applies to confirm prompts you generate.
145
145
  - No tables, no markdown grids, no emoji of any kind. Plain ASCII.
146
146
  - Never reference internal ids in your reply text. Use human names. (Tool call arguments are fine to use ids.)
147
- - If you genuinely cannot proceed (non-interactive mode and clarify is required), reply explaining what's missing.`;
148
- export const RESOLVE_PERSONA = `You are Plasalid ("ปลาสลิด"), currently working through every question the scanner couldn't resolve. The user message hands you EVERY question at once. Your goal is to close every one of them with as few user prompts as possible — automate the obvious cases first; ask only when judgment is genuinely required.
147
+ - If you genuinely cannot proceed (non-interactive mode and confirm is required), reply explaining what's missing.`;
148
+ export const CLARIFY_PERSONA = `You are Plasalid ("ปลาสลิด"), currently working through every question the scanner couldn't resolve. The user message hands you EVERY question at once. Your goal is to close every one of them with as few user prompts as possible — automate the obvious cases first; ask only when judgment is genuinely required.
149
149
 
150
150
  Inputs you receive:
151
151
  - One line per question in the user message: id, kind, transaction/account/file ids, prompt, options.
@@ -154,7 +154,7 @@ Inputs you receive:
154
154
 
155
155
  The workflow is five steps. Do them in order. Do not skip step 1.
156
156
 
157
- **Step 1 — Survey.** Read the entire question list. Build a mental map: which kinds appear, which questions share a merchant / descriptor / account pair, which rows a loaded memory rule covers, which kinds you can resolve via heuristic alone. The goal is to know the whole shape before mutating anything.
157
+ **Step 1 — Survey.** Read the entire question list. Build a mental map: which kinds appear, which questions share a merchant / descriptor / account pair, which rows a loaded memory rule covers, which kinds you can clarify via heuristic alone. The goal is to know the whole shape before mutating anything.
158
158
 
159
159
  **Step 2 — Apply memory-driven silent resolutions.** For every question a loaded memory rule covers (merchant→category, known recurrence identity, "these two accounts are separate", account-purpose fact), apply the implied mutation, then call \`close_question\` with the implied answer. Group sibling questions under one \`close_question\` call via \`related_question_ids\` — one call per memory rule, not one per row.
160
160
 
@@ -178,7 +178,7 @@ For each group, call \`ask_user\` ONCE, passing every sibling's id in \`related_
178
178
 
179
179
  **Step 5 — Learn and finalize.** After every non-skip user answer that implies a generalizable rule (e.g. "Lazada on KTC Card → Shopping"), call \`save_memory(content=<rule>, category="scanning_hint")\` so the next scan applies it silently. For merchant categorization, also call \`set_merchant_default_account\`. Phrase rules as reusable classifications, not one-event records (GOOD: "Lazada Thailand on KTC Card ••5678 → expense:shopping." BAD: "On 2026-03-15 the user said Shopping.").
180
180
 
181
- **Closing invariant.** Every question in the input list must have \`resolved_at\` set by the end. If anything is still open after step 4, close it with \`close_question(answer="Skip — could not interpret")\`. The pipeline reads the DB after you finish — if any question is still open it will re-invoke you with the leftovers, so always finish each row before yielding.
181
+ **Closing invariant.** Every question in the input list must be closed by the end. Closing deletes the row from the \`questions\` table. If anything is still open after step 4, close it with \`close_question(answer="Skip — could not interpret")\`. The pipeline reads the DB after you finish — if any question is still open it will re-invoke you with the leftovers, so always finish each row before yielding.
182
182
 
183
183
  **Tool errors.** If a tool result comes back marked as an error (e.g. a malformed id, a row that no longer exists, a constraint violation), do NOT call \`close_question\` for the affected row. Either fix the input and retry the same mutation, or close that one row with \`close_question(answer="Skip — tool error: <short reason>")\` so the loop can move on. Never close a row whose underlying mutation failed.
184
184
 
@@ -12,13 +12,13 @@ import type Database from "libsql";
12
12
  /** Date headers */
13
13
  /** Long-form date for chat ("Today is Friday, March 5, 2026."). */
14
14
  export declare function renderTodayHuman(): string;
15
- /** ISO date for scan/resolve ("Today is 2026-03-05."). */
15
+ /** ISO date for scan/clarify ("Today is 2026-03-05."). */
16
16
  export declare function renderTodayIso(): string;
17
17
  /** Chart of accounts */
18
18
  export interface ChartOfAccountsOptions {
19
19
  withBalance: boolean;
20
- /** Empty-state copy. `scan` hints at creating accounts; `resolve` is terse. */
21
- emptyState: "scan" | "resolve";
20
+ /** Empty-state copy. `scan` hints at creating accounts; `clarify` is terse. */
21
+ emptyState: "scan" | "clarify";
22
22
  }
23
23
  export declare function renderChartOfAccounts(db: Database.Database, opts: ChartOfAccountsOptions): string;
24
24
  /**
@@ -37,7 +37,7 @@ export interface MemoriesOptions {
37
37
  showCategory: boolean;
38
38
  }
39
39
  export declare function renderMemories(db: Database.Database, opts: MemoriesOptions): string | null;
40
- /** Resolve scope */
40
+ /** Clarify scope */
41
41
  export interface ScopeOptions {
42
42
  accountId?: string;
43
43
  from?: string;
@@ -21,7 +21,7 @@ export function renderTodayHuman() {
21
21
  year: "numeric",
22
22
  })}.`;
23
23
  }
24
- /** ISO date for scan/resolve ("Today is 2026-03-05."). */
24
+ /** ISO date for scan/clarify ("Today is 2026-03-05."). */
25
25
  export function renderTodayIso() {
26
26
  return `Today is ${new Date().toISOString().slice(0, 10)}.`;
27
27
  }
@@ -2,7 +2,7 @@ import type Database from "libsql";
2
2
  export interface ScanPromptOptions {
3
3
  fileName: string;
4
4
  }
5
- export interface ResolvePromptOptions {
5
+ export interface ClarifyPromptOptions {
6
6
  accountId?: string;
7
7
  from?: string;
8
8
  to?: string;
@@ -18,6 +18,6 @@ export interface RecordPromptOptions {
18
18
  * shuffle the array.
19
19
  */
20
20
  export declare function buildChatSystemPrompt(db: Database.Database): string;
21
- export declare function buildResolveSystemPrompt(db: Database.Database, opts: ResolvePromptOptions): string;
21
+ export declare function buildClarifySystemPrompt(db: Database.Database, opts: ClarifyPromptOptions): string;
22
22
  export declare function buildRecordSystemPrompt(db: Database.Database, opts: RecordPromptOptions): string;
23
23
  export declare function buildScanSystemPrompt(db: Database.Database, opts: ScanPromptOptions): string;
@@ -1,6 +1,6 @@
1
1
  import { config } from "../config.js";
2
2
  import { readContext } from "./context.js";
3
- import { chatPersona, SCAN_PERSONA, RESOLVE_PERSONA, RECORD_PERSONA } from "./personas.js";
3
+ import { chatPersona, SCAN_PERSONA, CLARIFY_PERSONA, RECORD_PERSONA } from "./personas.js";
4
4
  import { getThaiTaxonomyHint } from "../accounts/taxonomy.js";
5
5
  import { renderChartOfAccounts, renderChatChartOrEmpty, renderMemories, renderScope, renderTodayHuman, renderTodayIso, renderUserContext, } from "./prompt-sections.js";
6
6
  /**
@@ -23,11 +23,11 @@ export function buildChatSystemPrompt(db) {
23
23
  }),
24
24
  ]);
25
25
  }
26
- export function buildResolveSystemPrompt(db, opts) {
26
+ export function buildClarifySystemPrompt(db, opts) {
27
27
  return joinSections([
28
- RESOLVE_PERSONA,
28
+ CLARIFY_PERSONA,
29
29
  renderTodayIso(),
30
- renderChartOfAccounts(db, { withBalance: true, emptyState: "resolve" }),
30
+ renderChartOfAccounts(db, { withBalance: true, emptyState: "clarify" }),
31
31
  renderScope(opts),
32
32
  renderMemories(db, {
33
33
  header: "Rules you've already learned (apply directly; do not re-ask the user)",
@@ -0,0 +1,2 @@
1
+ import type { ToolModule } from "./types.js";
2
+ export declare const clarifyTools: ToolModule;
@@ -0,0 +1,169 @@
1
+ import { deleteTransaction, updateTransaction, updatePosting, } from "../../db/queries/transactions.js";
2
+ import { mergeAccounts } from "../../db/queries/account-balance.js";
3
+ import { linkTransactionToRecurrence, recordRecurrence, } from "../../db/queries/recurrences.js";
4
+ import { sanitizeForPrompt } from "../sanitize.js";
5
+ const DEFS = [
6
+ {
7
+ name: "update_transaction",
8
+ description: "Header-only update: date, description, or source_page. To change amounts, delete the transaction and record a new one.",
9
+ input_schema: {
10
+ type: "object",
11
+ properties: {
12
+ transaction_id: { type: "string" },
13
+ date: { type: "string" },
14
+ description: { type: "string" },
15
+ source_page: { type: "number" },
16
+ },
17
+ required: ["transaction_id"],
18
+ },
19
+ },
20
+ {
21
+ name: "update_posting",
22
+ description: "Safe single-posting edit: re-categorize (account_id) or update memo. Refuses changes to debit/credit/currency — delete and re-record the transaction for those.",
23
+ input_schema: {
24
+ type: "object",
25
+ properties: {
26
+ posting_id: { type: "string" },
27
+ account_id: { type: "string" },
28
+ memo: { type: "string" },
29
+ },
30
+ required: ["posting_id"],
31
+ },
32
+ },
33
+ {
34
+ name: "delete_transaction",
35
+ description: "Delete a transaction and (via cascade) all its postings. The primitive for removing duplicates.",
36
+ input_schema: {
37
+ type: "object",
38
+ properties: { transaction_id: { type: "string" } },
39
+ required: ["transaction_id"],
40
+ },
41
+ },
42
+ {
43
+ name: "record_recurrence",
44
+ description: "Create a recurrences row and link every supplied transaction to it. Computes first_seen_date, last_seen_date, and next_expected_date from the member transactions. Use this after the user confirms a recurrence_candidate question.",
45
+ input_schema: {
46
+ type: "object",
47
+ properties: {
48
+ account_id: {
49
+ type: "string",
50
+ description: "The account this recurs on.",
51
+ },
52
+ description: {
53
+ type: "string",
54
+ description: "Human label, e.g. 'Spotify subscription', 'Salary', 'Rent'.",
55
+ },
56
+ frequency: {
57
+ type: "string",
58
+ enum: ["weekly", "biweekly", "monthly", "annually"],
59
+ },
60
+ amount_typical: {
61
+ type: "number",
62
+ description: "Representative amount (typically the matching amount of the member transactions).",
63
+ },
64
+ currency: { type: "string", default: "THB" },
65
+ transaction_ids: {
66
+ type: "array",
67
+ items: { type: "string" },
68
+ description: "Transaction ids to link to this recurrence.",
69
+ },
70
+ notes: {
71
+ type: "string",
72
+ description: "Optional context the chat agent can read later.",
73
+ },
74
+ },
75
+ required: ["account_id", "description", "frequency", "transaction_ids"],
76
+ },
77
+ },
78
+ {
79
+ name: "link_transaction_to_recurrence",
80
+ description: "Attach a single newly-seen transaction to an existing recurrence. Recomputes last_seen_date and next_expected_date on the recurrence.",
81
+ input_schema: {
82
+ type: "object",
83
+ properties: {
84
+ transaction_id: { type: "string" },
85
+ recurrence_id: { type: "string" },
86
+ },
87
+ required: ["transaction_id", "recurrence_id"],
88
+ },
89
+ },
90
+ {
91
+ name: "merge_accounts",
92
+ description: "Move every posting on `from_id` over to `to_id`, then delete the source account. Use to apply a similar_accounts question's 'Merge A into B' resolution. Refuses if the source still has child accounts.",
93
+ input_schema: {
94
+ type: "object",
95
+ properties: { from_id: { type: "string" }, to_id: { type: "string" } },
96
+ required: ["from_id", "to_id"],
97
+ },
98
+ },
99
+ ];
100
+ const LABELS = {
101
+ update_transaction: "Updating transaction",
102
+ update_posting: "Updating posting",
103
+ delete_transaction: "Deleting transaction",
104
+ record_recurrence: "Recording recurrence",
105
+ link_transaction_to_recurrence: "Linking transaction to recurrence",
106
+ merge_accounts: "Merging accounts",
107
+ };
108
+ async function execute(db, name, input, _ctx) {
109
+ switch (name) {
110
+ case "update_transaction": {
111
+ const changed = updateTransaction(db, input.transaction_id, {
112
+ date: input.date,
113
+ description: input.description,
114
+ source_page: input.source_page,
115
+ });
116
+ return changed === 0
117
+ ? `Transaction ${input.transaction_id} not found or no fields to update.`
118
+ : `Updated transaction ${input.transaction_id}.`;
119
+ }
120
+ case "update_posting": {
121
+ const changed = updatePosting(db, input.posting_id, {
122
+ account_id: input.account_id,
123
+ memo: input.memo,
124
+ });
125
+ return changed === 0
126
+ ? `Posting ${input.posting_id} not found or no fields to update.`
127
+ : `Updated posting ${input.posting_id}.`;
128
+ }
129
+ case "delete_transaction": {
130
+ const changed = deleteTransaction(db, input.transaction_id);
131
+ return changed === 0
132
+ ? `Transaction ${input.transaction_id} not found.`
133
+ : `Deleted transaction ${input.transaction_id} and its postings.`;
134
+ }
135
+ case "record_recurrence": {
136
+ try {
137
+ const id = recordRecurrence(db, {
138
+ account_id: input.account_id,
139
+ description: input.description,
140
+ frequency: input.frequency,
141
+ amount_typical: input.amount_typical ?? null,
142
+ currency: input.currency,
143
+ transaction_ids: input.transaction_ids || [],
144
+ notes: input.notes ?? null,
145
+ });
146
+ return `Recorded recurrence ${id} ("${sanitizeForPrompt(input.description)}", ${input.frequency}); linked ${(input.transaction_ids || []).length} transaction(s).`;
147
+ }
148
+ catch (err) {
149
+ return `Could not record recurrence: ${err.message}`;
150
+ }
151
+ }
152
+ case "link_transaction_to_recurrence": {
153
+ try {
154
+ linkTransactionToRecurrence(db, input.transaction_id, input.recurrence_id);
155
+ return `Linked transaction ${input.transaction_id} → recurrence ${input.recurrence_id}.`;
156
+ }
157
+ catch (err) {
158
+ return `Could not link: ${err.message}`;
159
+ }
160
+ }
161
+ case "merge_accounts": {
162
+ const moved = mergeAccounts(db, input.from_id, input.to_id);
163
+ return `Merged ${input.from_id} → ${input.to_id}; moved ${moved} posting(s).`;
164
+ }
165
+ default:
166
+ return undefined;
167
+ }
168
+ }
169
+ export const clarifyTools = { DEFS, LABELS, execute };
@@ -1,8 +1,8 @@
1
1
  import { commonTools } from "./common.js";
2
2
  import { readTools } from "./read.js";
3
- import { accountIngestTools, scanQuestionTools, resolveIngestTools } from "./ingest.js";
3
+ import { accountIngestTools, scanQuestionTools, clarifyIngestTools } from "./ingest.js";
4
4
  import { scanTools } from "./scan.js";
5
- import { resolveTools } from "./resolve.js";
5
+ import { clarifyTools } from "./clarify.js";
6
6
  import { recordTools } from "./record.js";
7
7
  import { merchantTools } from "./merchants.js";
8
8
  /**
@@ -13,7 +13,7 @@ import { merchantTools } from "./merchants.js";
13
13
  const PROFILES = {
14
14
  scan: [commonTools, accountIngestTools, scanQuestionTools, scanTools, merchantTools],
15
15
  chat: [commonTools, readTools],
16
- resolve: [commonTools, readTools, accountIngestTools, resolveIngestTools, resolveTools, merchantTools],
16
+ clarify: [commonTools, readTools, accountIngestTools, clarifyIngestTools, clarifyTools, merchantTools],
17
17
  record: [commonTools, readTools, accountIngestTools, recordTools, merchantTools],
18
18
  };
19
19
  export function getToolDefinitions(profile) {
@@ -24,9 +24,9 @@ const MODULES = [
24
24
  readTools,
25
25
  accountIngestTools,
26
26
  scanQuestionTools,
27
- resolveIngestTools,
27
+ clarifyIngestTools,
28
28
  scanTools,
29
- resolveTools,
29
+ clarifyTools,
30
30
  recordTools,
31
31
  merchantTools,
32
32
  ];
@@ -49,9 +49,9 @@ export const TOOL_LABELS = {
49
49
  ...readTools.LABELS,
50
50
  ...accountIngestTools.LABELS,
51
51
  ...scanQuestionTools.LABELS,
52
- ...resolveIngestTools.LABELS,
52
+ ...clarifyIngestTools.LABELS,
53
53
  ...scanTools.LABELS,
54
- ...resolveTools.LABELS,
54
+ ...clarifyTools.LABELS,
55
55
  ...recordTools.LABELS,
56
56
  ...merchantTools.LABELS,
57
57
  };
@@ -1,4 +1,4 @@
1
1
  import type { ToolModule } from "./types.js";
2
2
  export declare const accountIngestTools: ToolModule;
3
3
  export declare const scanQuestionTools: ToolModule;
4
- export declare const resolveIngestTools: ToolModule;
4
+ export declare const clarifyIngestTools: ToolModule;