plasalid 0.5.8 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -9
- package/dist/accounts/taxonomy.d.ts +1 -1
- package/dist/accounts/taxonomy.js +2 -2
- package/dist/ai/agent.d.ts +7 -6
- package/dist/ai/agent.js +9 -8
- package/dist/ai/personas.d.ts +1 -1
- package/dist/ai/personas.js +69 -66
- package/dist/ai/prompt-sections.d.ts +4 -5
- package/dist/ai/prompt-sections.js +11 -11
- package/dist/ai/system-prompt.d.ts +2 -3
- package/dist/ai/system-prompt.js +5 -5
- package/dist/ai/tools/common.js +13 -5
- package/dist/ai/tools/index.js +15 -15
- package/dist/ai/tools/ingest.d.ts +2 -2
- package/dist/ai/tools/ingest.js +210 -87
- package/dist/ai/tools/merchants.js +27 -12
- package/dist/ai/tools/read.js +36 -20
- package/dist/ai/tools/record.js +79 -19
- package/dist/ai/tools/resolve.d.ts +2 -0
- package/dist/ai/tools/resolve.js +195 -0
- package/dist/ai/tools/types.d.ts +5 -7
- package/dist/cli/commands/accounts.js +2 -2
- package/dist/cli/commands/record.js +4 -2
- package/dist/cli/commands/resolve.d.ts +2 -0
- package/dist/cli/commands/resolve.js +13 -0
- package/dist/cli/commands/scan.js +18 -22
- package/dist/cli/commands/status.js +4 -2
- package/dist/cli/index.js +9 -9
- package/dist/cli/ink/hooks/useFooterText.js +1 -1
- package/dist/cli/ink/hooks/useTextInput.js +0 -3
- package/dist/cli/ink/scan_dashboard.d.ts +2 -2
- package/dist/cli/ink/scan_dashboard.js +3 -3
- package/dist/cli/setup.js +6 -3
- package/dist/cli/ux.js +1 -1
- package/dist/db/queries/account-balance.d.ts +140 -0
- package/dist/db/queries/account-balance.js +355 -0
- package/dist/db/queries/account_balance.d.ts +0 -1
- package/dist/db/queries/account_balance.js +0 -10
- package/dist/db/queries/action-log.d.ts +29 -0
- package/dist/db/queries/action-log.js +27 -0
- package/dist/db/queries/action_log.d.ts +1 -1
- package/dist/db/queries/concerns.d.ts +10 -0
- package/dist/db/queries/concerns.js +21 -0
- package/dist/db/queries/transactions.d.ts +3 -22
- package/dist/db/queries/transactions.js +4 -5
- package/dist/db/queries/unknowns.d.ts +62 -0
- package/dist/db/queries/unknowns.js +114 -0
- package/dist/db/schema.js +3 -3
- package/dist/resolver/pipeline.d.ts +16 -0
- package/dist/resolver/pipeline.js +38 -0
- package/dist/resolver/prompts.d.ts +8 -0
- package/dist/resolver/prompts.js +26 -0
- package/dist/scanner/account-mutex.d.ts +1 -0
- package/dist/scanner/account-mutex.js +16 -0
- package/dist/scanner/buffer.d.ts +10 -10
- package/dist/scanner/buffer.js +15 -15
- package/dist/scanner/decrypt-queue.d.ts +57 -0
- package/dist/scanner/decrypt-queue.js +114 -0
- package/dist/scanner/detectors/correlations.d.ts +2 -0
- package/dist/scanner/detectors/correlations.js +51 -0
- package/dist/scanner/detectors/duplicates.d.ts +2 -0
- package/dist/scanner/detectors/duplicates.js +75 -0
- package/dist/scanner/detectors/index.d.ts +18 -0
- package/dist/scanner/detectors/index.js +39 -0
- package/dist/scanner/detectors/recurrences.d.ts +2 -0
- package/dist/scanner/detectors/recurrences.js +49 -0
- package/dist/scanner/detectors/similar_accounts.d.ts +2 -0
- package/dist/scanner/detectors/similar_accounts.js +64 -0
- package/dist/scanner/detectors/similarities.d.ts +2 -0
- package/dist/scanner/detectors/similarities.js +73 -0
- package/dist/scanner/detectors/types.d.ts +16 -0
- package/dist/scanner/detectors/types.js +1 -0
- package/dist/scanner/inspectors/correlations.d.ts +2 -0
- package/dist/scanner/inspectors/correlations.js +47 -0
- package/dist/scanner/inspectors/duplicates.d.ts +2 -0
- package/dist/scanner/inspectors/duplicates.js +75 -0
- package/dist/scanner/inspectors/index.d.ts +19 -0
- package/dist/scanner/inspectors/index.js +39 -0
- package/dist/scanner/inspectors/recurrences.d.ts +2 -0
- package/dist/scanner/inspectors/recurrences.js +49 -0
- package/dist/scanner/inspectors/similarities.d.ts +2 -0
- package/dist/scanner/inspectors/similarities.js +73 -0
- package/dist/scanner/inspectors/types.d.ts +16 -0
- package/dist/scanner/inspectors/types.js +1 -0
- package/dist/scanner/pipeline.d.ts +6 -4
- package/dist/scanner/pipeline.js +51 -88
- package/dist/scanner/prompts.js +2 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -19,21 +19,21 @@ In markets like Thailand there's no Plaid: no public API that gives apps a unifi
|
|
|
19
19
|
|
|
20
20
|
## Features
|
|
21
21
|
|
|
22
|
-
Plasalid is a chain of three stages: **Scan →
|
|
22
|
+
Plasalid is a chain of three stages: **Scan → Resolve → Chat.** Underneath sits a three-layer ledger: hierarchical accounts (small, stable, colon-path ids like `expense:food:groceries`), deduplicated merchants (raw statement descriptors collapse to one canonical name with a learned default category), and balanced transactions with postings. Today's chat is one consumer; the same data will power a local MCP / API server next.
|
|
23
23
|
|
|
24
24
|
### Scan — parse without blocking
|
|
25
25
|
|
|
26
26
|
- **Drop PDFs in, get balanced transactions out.** The scanner infers account type, masks account numbers, converts Buddhist-Era dates, and posts a double-entry record for every transaction.
|
|
27
27
|
- **Merchants as first-class.** Statement descriptors (`STARBUCKS #1234 BKK`, `Starbucks #5678 BANGKOK`) normalize to one canonical merchant. Categorize a merchant once; future statements use the cached default category — the LLM skips re-categorizing known merchants.
|
|
28
|
-
- **Never pauses to ask you.** Ambiguous rows post best-guess transactions with a structured *
|
|
28
|
+
- **Never pauses to ask you.** Ambiguous rows post best-guess transactions with a structured *unknown* attached; lines the scanner can't confidently categorize land in `expense:uncategorized` for the resolve cleanup pass; unparseable rows are skipped, not guessed. A missing row is better than a wrong row — resolve clears them up later.
|
|
29
29
|
- **Encrypted PDFs handled inline.** Statement password-protected? Plasalid prompts you once, remembers the password (AES-GCM at rest) under a filename pattern, and unlocks next month's statement silently.
|
|
30
30
|
|
|
31
|
-
###
|
|
31
|
+
### Resolve — close every open unknown
|
|
32
32
|
|
|
33
33
|
- **Uncategorized cleanup.** Every posting parked in `expense:uncategorized` shows up here; categorizing one teaches the merchant's default account for next time, so a single answer can resolve dozens of rows across future months.
|
|
34
34
|
- **Connects related transactions.** A transfer that lands on both a bank statement and a credit-card statement is surfaced as one pair; merge on confirmation.
|
|
35
35
|
- **Recurrences as first-class data.** Spotify, salary, rent get their own `recurrences` rows with cadence (weekly / biweekly / monthly / annually) and next-expected dates, linked back to every member transaction. Not a UI category — a structured fact any AI consumer can read.
|
|
36
|
-
- **Step-by-step clarification.** Re-poses every scan-noted
|
|
36
|
+
- **Step-by-step clarification.** Re-poses every scan-noted unknown as one focused question; loops until unknowns are clear or you skip them.
|
|
37
37
|
|
|
38
38
|
### Chat — ask questions about your data
|
|
39
39
|
|
|
@@ -66,14 +66,14 @@ Then:
|
|
|
66
66
|
|
|
67
67
|
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.
|
|
68
68
|
2. Run `plasalid scan` — it parses your PDFs end-to-end without stopping.
|
|
69
|
-
3. Run `plasalid
|
|
69
|
+
3. Run `plasalid resolve` to connect related transactions, learn your recurring rhythms, and clear up anything the scanner flagged as a unknown.
|
|
70
70
|
4. Run `plasalid` to chat with what was scanned.
|
|
71
71
|
|
|
72
72
|
Other day-to-day commands:
|
|
73
73
|
|
|
74
74
|
- `plasalid scan <regex>` — only scan files whose path matches the regex.
|
|
75
75
|
- `plasalid scan <regex> --force` — re-scan matching files (replaces prior records).
|
|
76
|
-
- `plasalid
|
|
76
|
+
- `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`.
|
|
77
77
|
- `plasalid revert <regex>` — delete scanned files matching the regex and every transaction derived from them.
|
|
78
78
|
|
|
79
79
|
## Commands
|
|
@@ -90,7 +90,7 @@ plasalid transactions # List transactions and their postings (filt
|
|
|
90
90
|
plasalid record <utterance> # Add a manual transaction, account, balance, or merchant from a plain-language line
|
|
91
91
|
plasalid scan [regex] [--force] # Scan new PDFs; --force cascade-deletes prior records before re-scanning
|
|
92
92
|
plasalid revert <regex> # Delete scanned files matching <regex> and their transactions
|
|
93
|
-
plasalid
|
|
93
|
+
plasalid resolve # Walk every open unknown and apply your decision (--account, --from, --to, --kind also accepted)
|
|
94
94
|
```
|
|
95
95
|
|
|
96
96
|
## How It Works
|
|
@@ -109,7 +109,7 @@ plasalid review [--dry-run] # Connect related transactions, learn recurr
|
|
|
109
109
|
Claude API (PII-redacted)
|
|
110
110
|
│
|
|
111
111
|
┌──────────▼──────────┐
|
|
112
|
-
│ Encrypted DB │◀──── plasalid
|
|
112
|
+
│ Encrypted DB │◀──── plasalid resolve
|
|
113
113
|
└──────────┬──────────┘
|
|
114
114
|
│
|
|
115
115
|
plasalid
|
|
@@ -138,7 +138,7 @@ Plasalid stores everything in `~/.plasalid/`:
|
|
|
138
138
|
data/ # Drop any PDFs here (subfolders allowed; AI classifies)
|
|
139
139
|
```
|
|
140
140
|
|
|
141
|
-
`db.sqlite` holds the three-layer ledger (hierarchical accounts, deduplicated merchants with learned default categories, transactions and postings), scan history, open
|
|
141
|
+
`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
142
|
|
|
143
143
|
### Environment Variables
|
|
144
144
|
|
|
@@ -24,7 +24,7 @@ export declare const SUGGESTED_LIABILITY_SUBTYPES: string[];
|
|
|
24
24
|
export declare const SUGGESTED_EXPENSE_SUBTYPES: string[];
|
|
25
25
|
export declare const SUGGESTED_INCOME_SUBTYPES: string[];
|
|
26
26
|
/**
|
|
27
|
-
* Stringified Thai taxonomy block for the scan/
|
|
27
|
+
* Stringified Thai taxonomy block for the scan/resolve system prompts.
|
|
28
28
|
* Lists known Thai institutions and suggested subtypes so the model picks
|
|
29
29
|
* consistent `bank_name` and `subtype` values across statements.
|
|
30
30
|
*/
|
|
@@ -122,7 +122,7 @@ export const ACCOUNT_TYPE_DESCRIPTIONS = {
|
|
|
122
122
|
liability: "Credit cards, loans, mortgages, money the user owes.",
|
|
123
123
|
income: "Salary, side income, dividends, refunds.",
|
|
124
124
|
expense: "Spending categories (food, transport, utilities, etc.).",
|
|
125
|
-
equity: "Owner's equity / opening balance equity (for
|
|
125
|
+
equity: "Owner's equity / opening balance equity (for ledger adjustments).",
|
|
126
126
|
};
|
|
127
127
|
export const SUGGESTED_ASSET_SUBTYPES = [
|
|
128
128
|
"bank",
|
|
@@ -169,7 +169,7 @@ export 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/resolve 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 ResolvePromptOptions, 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";
|
|
@@ -41,14 +41,15 @@ export declare function runRecordAgent(opts: {
|
|
|
41
41
|
signal?: AbortSignal;
|
|
42
42
|
}): Promise<string>;
|
|
43
43
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* Resolve-time agent loop. The pipeline calls this once per open unknown with
|
|
45
|
+
* that unknown's id/prompt/options in the initial messages. The agent surfaces
|
|
46
|
+
* the question, applies the user's chosen answer via mutation tools, and
|
|
47
|
+
* finishes with mark_resolve_done.
|
|
47
48
|
*/
|
|
48
|
-
export declare function
|
|
49
|
+
export declare function runResolveAgent(opts: {
|
|
49
50
|
db: Database.Database;
|
|
50
51
|
initialMessages: NormalizedMessage[];
|
|
51
|
-
prompt:
|
|
52
|
+
prompt: ResolvePromptOptions;
|
|
52
53
|
agentCtx: AgentExecutionContext;
|
|
53
54
|
onProgress?: ProgressCallback;
|
|
54
55
|
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, buildResolveSystemPrompt, 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 { redact, unredact } from "./redactor.js";
|
|
@@ -155,21 +155,22 @@ export async function runRecordAgent(opts) {
|
|
|
155
155
|
return text;
|
|
156
156
|
}
|
|
157
157
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
158
|
+
* Resolve-time agent loop. The pipeline calls this once per open unknown with
|
|
159
|
+
* that unknown's id/prompt/options in the initial messages. The agent surfaces
|
|
160
|
+
* the question, applies the user's chosen answer via mutation tools, and
|
|
161
|
+
* finishes with mark_resolve_done.
|
|
161
162
|
*/
|
|
162
|
-
export async function
|
|
163
|
-
const systemPrompt = redact(
|
|
163
|
+
export async function runResolveAgent(opts) {
|
|
164
|
+
const systemPrompt = redact(buildResolveSystemPrompt(opts.db, opts.prompt));
|
|
164
165
|
const { text } = await runAgent({
|
|
165
166
|
db: opts.db,
|
|
166
167
|
systemPrompt,
|
|
167
|
-
tools: getToolDefinitions("
|
|
168
|
+
tools: getToolDefinitions("resolve"),
|
|
168
169
|
initialMessages: opts.initialMessages,
|
|
169
170
|
agentCtx: opts.agentCtx,
|
|
170
171
|
onProgress: opts.onProgress,
|
|
171
172
|
signal: opts.signal,
|
|
172
|
-
maxToolSteps:
|
|
173
|
+
maxToolSteps: 30,
|
|
173
174
|
});
|
|
174
175
|
return text;
|
|
175
176
|
}
|
package/dist/ai/personas.d.ts
CHANGED
package/dist/ai/personas.js
CHANGED
|
@@ -51,21 +51,21 @@ Rules:
|
|
|
51
51
|
- \`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"\`.
|
|
52
52
|
- \`alias\`: the exact raw statement descriptor. Plasalid normalizes and dedups it.
|
|
53
53
|
- \`default_account_id\`: when categorization is confident on first sight, set this to the matching expense account (e.g. \`expense:food:dining\` for Starbucks). The next scan that sees the same merchant will skip re-asking the LLM.
|
|
54
|
-
Also set \`raw_descriptor\` on the transaction to the exact statement line for downstream
|
|
54
|
+
Also set \`raw_descriptor\` on the transaction to the exact statement line for downstream lookups.
|
|
55
55
|
For transfers between own accounts and pure balance movements, omit the merchant block.
|
|
56
56
|
6. **Pre-resolved merchants.** If the prompt context shows a merchant already known for the descriptor, use the supplied \`merchant_id\` and \`default_account_id\` on \`record_transaction\` 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).
|
|
57
|
-
7. **Suspense fallback.** If you cannot categorize an expense with reasonable confidence, post the expense side to \`expense:uncategorized\` (auto-created on first use) and call \`
|
|
57
|
+
7. **Suspense fallback.** If you cannot categorize an expense with reasonable confidence, post the expense side to \`expense:uncategorized\` (auto-created on first use) and call \`note_unknown\` with \`kind="uncategorized_expense"\` and the just-posted transaction_id. Do **not** invent a category. The resolver batches these into one cleanup pass and learns the merchant's default from your fix.
|
|
58
58
|
8. Dates: convert Buddhist Era → Gregorian by subtracting 543 from the year. Store as YYYY-MM-DD.
|
|
59
59
|
9. Default currency is THB. Tag every posting with its ISO 4217 currency code on the \`record_transaction\` call; only deviate from THB when the row explicitly shows another currency (foreign-card purchases, FX transfers, multi-currency wallets).
|
|
60
60
|
10. Account numbers: store only the last 4 digits (mask the rest with bullets, e.g. \`••1234\`). Never persist the full account number.
|
|
61
61
|
11. If the document reveals an account that doesn't exist yet, call \`create_account\` once before posting transactions to it. Reuse existing accounts; don't create duplicates — call \`list_accounts\` first.
|
|
62
62
|
12. Persist account metadata when the document carries it: bank name, masked number, statement day, due day, points balance.
|
|
63
63
|
13. **Never pause for the user.** Your only job is to parse this document as accurately as possible.
|
|
64
|
-
- If a row is ambiguous (unclear category, unclear sign, suspicious total), still post your best-guess \`record_transaction\`, then call \`
|
|
65
|
-
- If a row is *unparseable* (amount unreadable, date missing entirely, can't tell what account is involved), **skip the row entirely** — do not call \`record_transaction\` with placeholder values. Call \`
|
|
66
|
-
- If you have a
|
|
67
|
-
- The
|
|
68
|
-
- **Apply what you've already been told.** Before flagging a
|
|
64
|
+
- If a row is ambiguous (unclear category, unclear sign, suspicious total), still post your best-guess \`record_transaction\`, then call \`note_unknown\` with the row's date, amount (฿N,NNN.NN), description, and exactly what you're unsure about. Pass the just-posted \`transaction_id\` so the resolver can find it.
|
|
65
|
+
- If a row is *unparseable* (amount unreadable, date missing entirely, can't tell what account is involved), **skip the row entirely** — do not call \`record_transaction\` with placeholder values. Call \`note_unknown\` with the raw row text and no \`transaction_id\`. A missing row is better than a wrong row.
|
|
66
|
+
- If you have a unknown 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_unknown\` with \`account_id\` set. You can combine \`account_id\` and \`transaction_id\` if a single row triggered the doubt.
|
|
67
|
+
- The resolver will work through unknowns later with the full picture across statements.
|
|
68
|
+
- **Apply what you've already been told.** Before flagging a unknown, 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 unknown. Only flag a unknown when the row genuinely doesn't fit any saved rule. Asking the user about something they've already told us is bad UX.
|
|
69
69
|
14. When the file is fully processed, call \`mark_file_scanned\` with a short summary.
|
|
70
70
|
|
|
71
71
|
Common Thai statement patterns to expect:
|
|
@@ -74,9 +74,9 @@ Common Thai statement patterns to expect:
|
|
|
74
74
|
- Payslips list gross salary, tax, social-security, and net pay.
|
|
75
75
|
- Transfer slips (PromptPay / mobile banking) show source account, destination account, amount, and a reference number.
|
|
76
76
|
|
|
77
|
-
How to phrase
|
|
78
|
-
- Write a complete sentence with enough context for a later
|
|
79
|
-
- 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
|
|
77
|
+
How to phrase note_unknown:
|
|
78
|
+
- 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.
|
|
79
|
+
- 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.
|
|
80
80
|
- 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.
|
|
81
81
|
|
|
82
82
|
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.`;
|
|
@@ -105,7 +105,9 @@ Action dispatch:
|
|
|
105
105
|
- REFUND ("got refund X to <account>" or "refund X from <merchant>"): DR <account>; for the credit side, prefer reversing the related expense category if one is obvious from the utterance or saved memory, otherwise CR income:refunds (auto-create on demand with parent_id=\`income\`). Attach the merchant block when the merchant is named. Never use adjust_account_balance for a refund — money moved.
|
|
106
106
|
- MULTI-ITEM single receipt ("lunch 200, drinks 100 from cash") → ONE \`record_transaction\` with one debit posting per item (each posting carries its own memo) and one credit posting totalling the sum. Don't split into separate transactions unless items are on different days or use different funding accounts.
|
|
107
107
|
- BALANCE update ("set / update / now has / is now / networth of / portfolio is X") → adjust_account_balance with target_balance = the stated amount. The tool reads the current balance and posts the delta against equity:adjustments.
|
|
108
|
-
- METADATA update ("set my KTC due day to 20", "statement day 28", "
|
|
108
|
+
- METADATA update ("set my KTC due day to 20", "statement day 28", "change masked number") → update_account_metadata. No money moved; no transaction.
|
|
109
|
+
- RENAME ("rename SCB to Bangkok Bank") → resolve the account via find_similar_accounts, then rename_account.
|
|
110
|
+
- 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.
|
|
109
111
|
- 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.
|
|
110
112
|
- MERCHANT teaching ("Starbucks is Dining", "mark Lazada as Shopping") → find_or_create_merchant with the canonical name and default_account_id. No transaction.
|
|
111
113
|
- "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.
|
|
@@ -133,61 +135,62 @@ Output rules:
|
|
|
133
135
|
- No tables, no markdown grids, no emoji of any kind. Plain ASCII.
|
|
134
136
|
- Never reference internal ids in your reply text. Use human names. (Tool call arguments are fine to use ids.)
|
|
135
137
|
- If you genuinely cannot proceed (non-interactive mode and clarify is required), reply explaining what's missing.`;
|
|
136
|
-
export const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
-
|
|
160
|
-
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
-
|
|
173
|
-
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
-
|
|
179
|
-
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
-
|
|
185
|
-
-
|
|
186
|
-
|
|
187
|
-
|
|
138
|
+
export const RESOLVE_PERSONA = `You are Plasalid's resolver. The user message hands you EVERY open unknown 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.
|
|
139
|
+
|
|
140
|
+
Inputs you receive:
|
|
141
|
+
- One line per open unknown in the user message: id, kind, transaction/account/file ids, prompt, options.
|
|
142
|
+
- The "Rules you've already learned" section in the system prompt — authoritative; apply silently.
|
|
143
|
+
- The current chart of accounts + balances in the system prompt.
|
|
144
|
+
|
|
145
|
+
The workflow is five steps. Do them in order. Do not skip step 1.
|
|
146
|
+
|
|
147
|
+
**Step 1 — Survey (no tool calls).** Read the entire unknown list. Build a mental map: which kinds appear, which unknowns 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.
|
|
148
|
+
|
|
149
|
+
**Step 2 — Apply memory-driven silent resolutions.** For every unknown 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_unknown\` with the implied answer. Group sibling unknowns under one \`close_unknown\` call via \`related_unknown_ids\` — one call per memory rule, not one per row.
|
|
150
|
+
|
|
151
|
+
**Step 3 — Apply per-kind heuristic defaults.** For unknowns not covered by memory, apply automatically when the heuristic is high-confidence:
|
|
152
|
+
- kind=\`duplicate\` — if the two transactions share the same merchant on the same date in the same file, default "Keep both" silently. (The inspector already drops these at source, but if one leaks through, suppress it here.)
|
|
153
|
+
- kind=\`correlation\` — if both sides are already linked to a recurrence, default "Keep separate" silently (recurring transfers aren't duplicates).
|
|
154
|
+
- kind=\`recurrence_candidate\` — if a memory rule names the recurrence (e.g. "Monthly ฿199 on KTC Card → Spotify subscription"), call \`record_recurrence\` with the candidate's transaction_ids and the implied frequency, then \`close_unknown\`.
|
|
155
|
+
- kind=\`uncategorized\` / \`uncategorized_expense\` — if the transaction's merchant already has a default_account_id set, apply that category via \`update_posting\` and \`close_unknown\`. No need to re-ask.
|
|
156
|
+
- kind=\`similar_accounts\` — if the two names differ only in casing/whitespace, that's a high-confidence merge; still group with a single \`ask_user\` (don't auto-merge without confirmation, but ask only once).
|
|
157
|
+
|
|
158
|
+
In each case, call \`close_unknown\` with the implied answer and \`related_unknown_ids\` if any siblings share that answer.
|
|
159
|
+
|
|
160
|
+
**Step 4 — Group remaining unknowns, then ask ONCE per group.** Whatever survives steps 2-3 needs the user. Group by shared answer:
|
|
161
|
+
- All \`uncategorized\` / \`uncategorized_expense\` unknowns on the same merchant or \`raw_descriptor\` → one group.
|
|
162
|
+
- All \`duplicate\` unknowns sharing the same pair of source files → one group.
|
|
163
|
+
- All \`correlation\` unknowns between the same pair of accounts → one group.
|
|
164
|
+
- All \`recurrence_candidate\` unknowns on the same account + amount → one group.
|
|
165
|
+
- All \`similar_accounts\` unknowns on the same account pair → one group (usually one row already).
|
|
166
|
+
|
|
167
|
+
For each group, call \`ask_user\` ONCE, passing every sibling's id in \`related_unknown_ids\`. Include "Skip — leave as is" as the last option. After the user answers, apply the mutation(s) the answer implies for every member of the group.
|
|
168
|
+
|
|
169
|
+
**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.").
|
|
170
|
+
|
|
171
|
+
**Closing invariant.** Every unknown in the input list must have \`resolved_at\` set by the end. If anything is still open after step 4, close it with \`close_unknown(answer="Skip — could not interpret")\`.
|
|
172
|
+
|
|
173
|
+
End with one \`mark_resolve_done\` call. Summary format: a single sentence with counts. Examples:
|
|
174
|
+
- "Applied 9 from memory; resolved 2 groups (5 unknowns) by user answer; deferred 1 via Skip."
|
|
175
|
+
- "All 14 unknowns applied silently from memory rules."
|
|
176
|
+
- "Resolved 1 group (3 Lazada postings) as Shopping; saved the merchant default."
|
|
177
|
+
|
|
178
|
+
Unknown kind → mutation tool map (use after a user answer in step 4):
|
|
179
|
+
- \`uncategorized\` / \`uncategorized_expense\` → \`update_posting(account_id=...)\` for each posting on the transaction. If the transaction has a merchant_id, also \`set_merchant_default_account\`.
|
|
180
|
+
- \`duplicate\` → "Delete this one" → \`delete_transaction\` on the unknown's transaction_id. "Delete the older one" → identify the older tx from the prompt body, then \`delete_transaction\`. "Keep both" / "Skip" → no mutation.
|
|
181
|
+
- \`correlation\` → "Merge into one transaction" → \`delete_transaction\` on one side and \`update_posting\` on the other so it reflects the cross-account movement. "Keep separate" / "Skip" → no mutation.
|
|
182
|
+
- \`recurrence_candidate\` → "Link as recurring" → \`record_recurrence\` with the candidate's transaction_ids and the implied frequency. "Not recurring" / "Skip" → no mutation.
|
|
183
|
+
- \`similar_accounts\` → "Merge A into B" / "Merge B into A" → \`merge_accounts(from_id, to_id)\`. "Keep separate" / "Skip" → no mutation.
|
|
184
|
+
|
|
185
|
+
How to phrase \`ask_user\`:
|
|
186
|
+
- Use the unknown's \`prompt\` verbatim (or a tightened version when grouping). Don't restate amounts/dates/accounts in prose — that's what \`facts\` is for.
|
|
187
|
+
- Pass the unknown's existing \`options\` verbatim. Don't invent options.
|
|
188
|
+
- Always pass the primary unknown's id as \`unknown_id\` and the siblings as \`related_unknown_ids\`.
|
|
189
|
+
- Populate \`facts\` whenever the unknown mentions an amount, date, merchant, or accounts (amount=yellow, date=cyan, merchant=green, accounts=magenta).
|
|
190
|
+
- Never reference internal ids (\`tx:…\`, \`asset:…\`, \`rc:…\`, \`cn:…\`) in the prompt text.
|
|
188
191
|
|
|
189
192
|
Output formatting:
|
|
190
|
-
- Use plain ASCII numbers (\`1.\`, \`2.\`, \`3.\`) for any lists
|
|
191
|
-
- Never use emoji of any kind
|
|
193
|
+
- Use plain ASCII numbers (\`1.\`, \`2.\`, \`3.\`) for any lists. Never use Unicode circled digits.
|
|
194
|
+
- Never use emoji of any kind — use plain words.
|
|
192
195
|
- Always reply in English.
|
|
193
|
-
- Be
|
|
196
|
+
- Be terse; the user wants the final summary, not narration.`;
|
|
@@ -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/resolve ("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; `resolve` is terse. */
|
|
21
|
+
emptyState: "scan" | "resolve";
|
|
22
22
|
}
|
|
23
23
|
export declare function renderChartOfAccounts(db: Database.Database, opts: ChartOfAccountsOptions): string;
|
|
24
24
|
/**
|
|
@@ -37,12 +37,11 @@ export interface MemoriesOptions {
|
|
|
37
37
|
showCategory: boolean;
|
|
38
38
|
}
|
|
39
39
|
export declare function renderMemories(db: Database.Database, opts: MemoriesOptions): string | null;
|
|
40
|
-
/**
|
|
40
|
+
/** Resolve scope */
|
|
41
41
|
export interface ScopeOptions {
|
|
42
42
|
accountId?: string;
|
|
43
43
|
from?: string;
|
|
44
44
|
to?: string;
|
|
45
|
-
dryRun: boolean;
|
|
46
45
|
}
|
|
47
46
|
export declare function renderScope(opts: ScopeOptions): string;
|
|
48
47
|
/** Chat user context */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getMemories } from "./memory.js";
|
|
2
|
-
import { getAccountBalances } from "../db/queries/
|
|
2
|
+
import { getAccountBalances, } from "../db/queries/account-balance.js";
|
|
3
3
|
import { stripControls } from "./sanitize.js";
|
|
4
4
|
/**
|
|
5
5
|
* Small, single-purpose renderers that produce one Markdown-ish section each.
|
|
@@ -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/resolve ("Today is 2026-03-05."). */
|
|
25
25
|
export function renderTodayIso() {
|
|
26
26
|
return `Today is ${new Date().toISOString().slice(0, 10)}.`;
|
|
27
27
|
}
|
|
@@ -51,7 +51,7 @@ export function renderChatChartOrEmpty(db, name) {
|
|
|
51
51
|
return `## Accounts on file\n${rows.join("\n")}`;
|
|
52
52
|
}
|
|
53
53
|
function renderHierarchical(balances, withBalance) {
|
|
54
|
-
const byId = new Map(balances.map(b => [b.id, b]));
|
|
54
|
+
const byId = new Map(balances.map((b) => [b.id, b]));
|
|
55
55
|
const depthCache = new Map();
|
|
56
56
|
const depth = (id) => {
|
|
57
57
|
if (depthCache.has(id))
|
|
@@ -65,16 +65,16 @@ function renderHierarchical(balances, withBalance) {
|
|
|
65
65
|
depthCache.set(id, d);
|
|
66
66
|
return d;
|
|
67
67
|
};
|
|
68
|
-
return balances.map(a => formatAccountRow(a, withBalance, depth(a.id)));
|
|
68
|
+
return balances.map((a) => formatAccountRow(a, withBalance, depth(a.id)));
|
|
69
69
|
}
|
|
70
70
|
export function renderMemories(db, opts) {
|
|
71
71
|
const all = getMemories(db);
|
|
72
72
|
const filtered = opts.filterCategories
|
|
73
|
-
? all.filter(m => opts.filterCategories.includes(m.category))
|
|
73
|
+
? all.filter((m) => opts.filterCategories.includes(m.category))
|
|
74
74
|
: all;
|
|
75
75
|
if (filtered.length === 0)
|
|
76
76
|
return null;
|
|
77
|
-
const lines = filtered.map(m => formatMemoryLine(m, opts.showCategory));
|
|
77
|
+
const lines = filtered.map((m) => formatMemoryLine(m, opts.showCategory));
|
|
78
78
|
return `## ${opts.header}\n${lines.join("\n")}`;
|
|
79
79
|
}
|
|
80
80
|
export function renderScope(opts) {
|
|
@@ -83,14 +83,12 @@ export function renderScope(opts) {
|
|
|
83
83
|
`- account: ${opts.accountId ?? "all"}`,
|
|
84
84
|
`- from: ${opts.from ?? "all time"}`,
|
|
85
85
|
`- to: ${opts.to ?? "now"}`,
|
|
86
|
-
`- dry run: ${opts.dryRun
|
|
87
|
-
? "yes — write tools will not mutate the DB"
|
|
88
|
-
: "no — write tools will mutate the DB after confirmation"}`,
|
|
89
86
|
].join("\n");
|
|
90
87
|
}
|
|
91
88
|
/** Chat user context */
|
|
92
89
|
export function renderUserContext(name, contextMd) {
|
|
93
|
-
const body = contextMd ??
|
|
90
|
+
const body = contextMd ??
|
|
91
|
+
`(No personal context on file yet. ${name} can edit ~/.plasalid/context.md to add family, income, or other facts.)`;
|
|
94
92
|
return `## About ${name}\n${body}`;
|
|
95
93
|
}
|
|
96
94
|
/** Internal formatters */
|
|
@@ -98,7 +96,9 @@ function formatAccountRow(a, withBalance, depth = 0) {
|
|
|
98
96
|
const indent = " ".repeat(depth);
|
|
99
97
|
const subtype = a.subtype ? `/${a.subtype}` : "";
|
|
100
98
|
const base = `- ${indent}${a.id} | ${a.name} | ${a.type}${subtype}`;
|
|
101
|
-
return withBalance
|
|
99
|
+
return withBalance
|
|
100
|
+
? `${base} | balance ${a.balance.toFixed(2)} ${a.currency}`
|
|
101
|
+
: base;
|
|
102
102
|
}
|
|
103
103
|
function formatMemoryLine(m, showCategory) {
|
|
104
104
|
return showCategory
|
|
@@ -2,11 +2,10 @@ import type Database from "libsql";
|
|
|
2
2
|
export interface ScanPromptOptions {
|
|
3
3
|
fileName: string;
|
|
4
4
|
}
|
|
5
|
-
export interface
|
|
5
|
+
export interface ResolvePromptOptions {
|
|
6
6
|
accountId?: string;
|
|
7
7
|
from?: string;
|
|
8
8
|
to?: string;
|
|
9
|
-
dryRun: boolean;
|
|
10
9
|
}
|
|
11
10
|
export interface RecordPromptOptions {
|
|
12
11
|
utterance: string;
|
|
@@ -19,6 +18,6 @@ export interface RecordPromptOptions {
|
|
|
19
18
|
* shuffle the array.
|
|
20
19
|
*/
|
|
21
20
|
export declare function buildChatSystemPrompt(db: Database.Database): string;
|
|
22
|
-
export declare function
|
|
21
|
+
export declare function buildResolveSystemPrompt(db: Database.Database, opts: ResolvePromptOptions): string;
|
|
23
22
|
export declare function buildRecordSystemPrompt(db: Database.Database, opts: RecordPromptOptions): string;
|
|
24
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, RESOLVE_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 buildResolveSystemPrompt(db, opts) {
|
|
27
27
|
return joinSections([
|
|
28
|
-
|
|
28
|
+
RESOLVE_PERSONA,
|
|
29
29
|
renderTodayIso(),
|
|
30
|
-
renderChartOfAccounts(db, { withBalance: true, emptyState: "
|
|
30
|
+
renderChartOfAccounts(db, { withBalance: true, emptyState: "resolve" }),
|
|
31
31
|
renderScope(opts),
|
|
32
32
|
renderMemories(db, {
|
|
33
33
|
header: "Rules you've already learned (apply directly; do not re-ask the user)",
|
|
@@ -56,7 +56,7 @@ export function buildScanSystemPrompt(db, opts) {
|
|
|
56
56
|
`## File context\nFile: ${opts.fileName}`,
|
|
57
57
|
`## Taxonomy hints\n${getThaiTaxonomyHint()}`,
|
|
58
58
|
renderMemories(db, {
|
|
59
|
-
header: "Rules you've already learned (apply silently before raising
|
|
59
|
+
header: "Rules you've already learned (apply silently before raising an unknown)",
|
|
60
60
|
filterCategories: ["scanning_hint", "general"],
|
|
61
61
|
showCategory: false,
|
|
62
62
|
}),
|
package/dist/ai/tools/common.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { saveMemory, getMemories } from "../memory.js";
|
|
2
|
-
import { getAccountBalances } from "../../db/queries/
|
|
2
|
+
import { getAccountBalances } from "../../db/queries/account-balance.js";
|
|
3
3
|
import { formatAmount } from "../../currency.js";
|
|
4
4
|
import { sanitizeForPrompt, sanitizeForPromptCell } from "../sanitize.js";
|
|
5
5
|
import { ACCOUNT_TYPE_DESCRIPTIONS } from "../../accounts/taxonomy.js";
|
|
@@ -11,7 +11,11 @@ const DEFS = [
|
|
|
11
11
|
input_schema: {
|
|
12
12
|
type: "object",
|
|
13
13
|
properties: {
|
|
14
|
-
type: {
|
|
14
|
+
type: {
|
|
15
|
+
type: "string",
|
|
16
|
+
enum: ACCOUNT_TYPES,
|
|
17
|
+
description: "Filter by account type.",
|
|
18
|
+
},
|
|
15
19
|
},
|
|
16
20
|
required: [],
|
|
17
21
|
},
|
|
@@ -23,7 +27,11 @@ const DEFS = [
|
|
|
23
27
|
type: "object",
|
|
24
28
|
properties: {
|
|
25
29
|
content: { type: "string", description: "What to remember." },
|
|
26
|
-
category: {
|
|
30
|
+
category: {
|
|
31
|
+
type: "string",
|
|
32
|
+
description: "Category: general, scanning_hint, preference, life_event.",
|
|
33
|
+
default: "general",
|
|
34
|
+
},
|
|
27
35
|
},
|
|
28
36
|
required: ["content"],
|
|
29
37
|
},
|
|
@@ -46,7 +54,7 @@ async function execute(db, name, input, _ctx) {
|
|
|
46
54
|
if (accounts.length === 0)
|
|
47
55
|
return "No accounts in the chart of accounts yet.";
|
|
48
56
|
return accounts
|
|
49
|
-
.map(a => {
|
|
57
|
+
.map((a) => {
|
|
50
58
|
const meta = [];
|
|
51
59
|
if (a.bank_name)
|
|
52
60
|
meta.push(sanitizeForPrompt(a.bank_name));
|
|
@@ -70,7 +78,7 @@ async function execute(db, name, input, _ctx) {
|
|
|
70
78
|
if (memories.length === 0)
|
|
71
79
|
return "No memories saved yet.";
|
|
72
80
|
return memories
|
|
73
|
-
.map(m => `[${m.category}] ${sanitizeForPrompt(m.content)} (saved ${m.created_at})`)
|
|
81
|
+
.map((m) => `[${m.category}] ${sanitizeForPrompt(m.content)} (saved ${m.created_at})`)
|
|
74
82
|
.join("\n");
|
|
75
83
|
}
|
|
76
84
|
default:
|