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.
- package/README.md +14 -14
- package/dist/accounts/taxonomy.d.ts +1 -1
- package/dist/accounts/taxonomy.js +1 -1
- package/dist/ai/agent.d.ts +5 -5
- package/dist/ai/agent.js +6 -6
- package/dist/ai/personas.d.ts +1 -1
- package/dist/ai/personas.js +14 -14
- package/dist/ai/prompt-sections.d.ts +4 -4
- package/dist/ai/prompt-sections.js +1 -1
- package/dist/ai/system-prompt.d.ts +2 -2
- package/dist/ai/system-prompt.js +4 -4
- package/dist/ai/tools/clarify.d.ts +2 -0
- package/dist/ai/tools/clarify.js +169 -0
- package/dist/ai/tools/index.js +7 -7
- package/dist/ai/tools/ingest.d.ts +1 -1
- package/dist/ai/tools/ingest.js +8 -8
- package/dist/ai/tools/read.js +1 -1
- package/dist/ai/tools/record.js +5 -5
- package/dist/ai/tools/types.d.ts +2 -2
- package/dist/cli/commands/clarify.d.ts +5 -0
- package/dist/cli/commands/clarify.js +44 -0
- package/dist/cli/commands/rules.js +1 -1
- package/dist/cli/commands/scan.js +9 -9
- package/dist/cli/commands/status.js +1 -1
- package/dist/cli/index.js +6 -6
- package/dist/cli/ink/ScanDashboard.d.ts +1 -1
- package/dist/cli/ink/ScanDashboard.js +2 -2
- package/dist/cli/setup.js +1 -1
- package/dist/cli/ux.js +1 -1
- package/dist/scanner/clarifier-memory.d.ts +8 -0
- package/dist/scanner/clarifier-memory.js +24 -0
- package/dist/scanner/clarifier.d.ts +39 -0
- package/dist/scanner/clarifier.js +196 -0
- package/dist/scanner/engine.d.ts +3 -3
- package/dist/scanner/engine.js +8 -8
- package/dist/scanner/hooks.d.ts +3 -3
- package/dist/scanner/worker.d.ts +1 -1
- package/dist/scanner/worker.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,15 +15,17 @@
|
|
|
15
15
|
|
|
16
16
|
<br />
|
|
17
17
|
|
|
18
|
-
In
|
|
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
|
-
|
|
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
|
|
22
|
+
Plasalid is a local data harness built to fix this. Think of it as a personal financial harness.
|
|
23
23
|
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
150
|
-
OPENAI_COMPATIBLE_BASE_URL= # e.g. http://localhost:11434/v1 (
|
|
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/
|
|
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/
|
|
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
|
*/
|
package/dist/ai/agent.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type Database from "libsql";
|
|
2
|
-
import { type ScanPromptOptions, type
|
|
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
|
|
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
|
-
*
|
|
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
|
|
48
|
+
export declare function runClarifyAgent(opts: {
|
|
49
49
|
db: Database.Database;
|
|
50
50
|
initialMessages: NormalizedMessage[];
|
|
51
|
-
prompt:
|
|
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,
|
|
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
|
|
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
|
-
*
|
|
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
|
|
181
|
-
const systemPrompt = redact(
|
|
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("
|
|
185
|
+
tools: getToolDefinitions("clarify"),
|
|
186
186
|
initialMessages: opts.initialMessages,
|
|
187
187
|
agentCtx: opts.agentCtx,
|
|
188
188
|
onProgress: opts.onProgress,
|
package/dist/ai/personas.d.ts
CHANGED
package/dist/ai/personas.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
104
|
-
4. If find_similar_accounts returns multiple matches >= 0.7 (e.g. user said "my saving" and there are two saving accounts), call
|
|
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
|
|
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
|
|
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
|
|
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
|
|
148
|
-
export const
|
|
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
|
|
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
|
|
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/
|
|
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; `
|
|
21
|
-
emptyState: "scan" | "
|
|
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
|
-
/**
|
|
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/
|
|
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
|
|
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
|
|
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;
|
package/dist/ai/system-prompt.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { config } from "../config.js";
|
|
2
2
|
import { readContext } from "./context.js";
|
|
3
|
-
import { chatPersona, SCAN_PERSONA,
|
|
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
|
|
26
|
+
export function buildClarifySystemPrompt(db, opts) {
|
|
27
27
|
return joinSections([
|
|
28
|
-
|
|
28
|
+
CLARIFY_PERSONA,
|
|
29
29
|
renderTodayIso(),
|
|
30
|
-
renderChartOfAccounts(db, { withBalance: true, emptyState: "
|
|
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,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 };
|
package/dist/ai/tools/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { commonTools } from "./common.js";
|
|
2
2
|
import { readTools } from "./read.js";
|
|
3
|
-
import { accountIngestTools, scanQuestionTools,
|
|
3
|
+
import { accountIngestTools, scanQuestionTools, clarifyIngestTools } from "./ingest.js";
|
|
4
4
|
import { scanTools } from "./scan.js";
|
|
5
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
27
|
+
clarifyIngestTools,
|
|
28
28
|
scanTools,
|
|
29
|
-
|
|
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
|
-
...
|
|
52
|
+
...clarifyIngestTools.LABELS,
|
|
53
53
|
...scanTools.LABELS,
|
|
54
|
-
...
|
|
54
|
+
...clarifyTools.LABELS,
|
|
55
55
|
...recordTools.LABELS,
|
|
56
56
|
...merchantTools.LABELS,
|
|
57
57
|
};
|