plasalid 0.3.5 → 0.4.1
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 +28 -39
- package/dist/accounts/taxonomy.d.ts +1 -1
- package/dist/accounts/taxonomy.js +2 -2
- package/dist/ai/agent.d.ts +6 -5
- package/dist/ai/agent.js +7 -6
- package/dist/ai/memory.d.ts +12 -5
- package/dist/ai/memory.js +12 -0
- package/dist/ai/personas.d.ts +10 -0
- package/dist/ai/personas.js +123 -0
- package/dist/ai/prompt-sections.d.ts +44 -0
- package/dist/ai/prompt-sections.js +89 -0
- package/dist/ai/system-prompt.d.ts +3 -3
- package/dist/ai/system-prompt.js +44 -165
- package/dist/ai/tools/index.js +12 -7
- package/dist/ai/tools/ingest.d.ts +2 -1
- package/dist/ai/tools/ingest.js +220 -83
- package/dist/ai/tools/read.js +31 -0
- package/dist/ai/tools/review.d.ts +2 -0
- package/dist/ai/tools/review.js +362 -0
- package/dist/ai/tools/scan.js +4 -2
- package/dist/ai/tools/types.d.ts +23 -3
- package/dist/cli/commands/review.d.ts +2 -0
- package/dist/cli/commands/review.js +15 -0
- package/dist/cli/commands/scan.d.ts +4 -2
- package/dist/cli/commands/scan.js +147 -19
- package/dist/cli/index.js +11 -8
- package/dist/cli/ink/scan_dashboard.d.ts +38 -0
- package/dist/cli/ink/scan_dashboard.js +62 -0
- package/dist/cli/ux.d.ts +2 -1
- package/dist/cli/ux.js +36 -2
- package/dist/db/queries/account_balance.d.ts +1 -0
- package/dist/db/queries/concerns.d.ts +47 -0
- package/dist/db/queries/concerns.js +87 -0
- package/dist/db/queries/journal.d.ts +74 -8
- package/dist/db/queries/journal.js +131 -19
- package/dist/db/queries/recurrences.d.ts +33 -0
- package/dist/db/queries/recurrences.js +130 -0
- package/dist/db/schema.js +25 -2
- package/dist/reviewer/pipeline.d.ts +18 -0
- package/dist/reviewer/pipeline.js +46 -0
- package/dist/reviewer/prompts.d.ts +12 -0
- package/dist/reviewer/prompts.js +22 -0
- package/dist/scanner/account_mutex.d.ts +1 -0
- package/dist/scanner/account_mutex.js +16 -0
- package/dist/scanner/buffer.d.ts +48 -0
- package/dist/scanner/buffer.js +63 -0
- package/dist/scanner/concurrency.d.ts +14 -0
- package/dist/scanner/concurrency.js +31 -0
- package/dist/scanner/decrypt_queue.d.ts +57 -0
- package/dist/scanner/decrypt_queue.js +96 -0
- package/dist/scanner/pipeline.d.ts +46 -18
- package/dist/scanner/pipeline.js +250 -97
- package/dist/scanner/prompts.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,59 +1,47 @@
|
|
|
1
1
|
<h1 align="center">Plasalid</h1>
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
-
<strong>
|
|
4
|
+
<strong>A local-first Plaid for your bank statements</strong>
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
|
|
8
|
+
Turn the PDFs you already receive into a structured, AI-readable journal — on your own machine.
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
<br />
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
In markets like Thailand there's no Plaid: no public API that gives apps a unified view of every account, no easy way to assemble a complete picture of your money. Knowing where you stand means logging into five bank apps one by one — and most people just don't bother. Plasalid is the missing layer: drop your bank and credit-card statement PDFs into a folder and Plasalid parses every transaction, balance, and fee into a double-entry SQLite database that lives on your machine. One source of truth for every account, ready for any AI to read.
|
|
15
15
|
|
|
16
|
-
Plasalid
|
|
17
|
-
|
|
18
|
-
In addition, personal finance isn't taught well in Thai schools. Fee-based advisors are out of reach for most households. The loudest "advice" channels are bank salespeople pitching their employer's products. The result: over **5 million Thais** are already flagged as non-performing borrowers, and a generation that wants to manage money better has nowhere accessible to learn how. Plasalid's bet is that capable AI changes that. The same intelligence that reads your statements can explain what the numbers mean. It flags what's about to go wrong. It coaches you through real decisions — debt, budget, savings.
|
|
19
|
-
|
|
20
|
-
And when survival isn't the question anymore, the same Plasalid can scales up with you. Saving for a trip. Building an emergency fund. Choosing investments. Planning a down payment or retirement. Working toward the freedom to walk away from a bad job. From getting out of debt to financial freedom, Plasalid grows with you.
|
|
16
|
+
Plasalid ships with a built-in chat so you can start asking questions right away, but the data layer is the product. A local MCP / API server is coming next, so any AI tool — Claude Desktop, your own scripts, dashboards, automations — can read the same journal without further integration. Local, private, yours.
|
|
21
17
|
|
|
22
18
|
## Features
|
|
23
19
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
- **Sees every balance, every transaction** — Plasalid's chat reads from your bank and credit-card statements, not generic categories. "Where did ฿14k go in March?" gets a specific answer.
|
|
27
|
-
- **Sharp and proactive** — Leads with the insight, not the breakdown. Flags concerning patterns (overdraft trajectory, unusual spending, payments due soon) even if you didn't ask.
|
|
28
|
-
- **Has a point of view** — When you ask "what should I do?", you get a recommendation, not a list of options.
|
|
29
|
-
- **Remembers what matters** — Persists biographical context (family, employer, goals) and per-statement scanning hints across sessions, so each conversation starts smarter than the last.
|
|
20
|
+
Plasalid is a chain of three stages: **Scan → Review → Chat.** Today's chat is one consumer of the journal; the same data will power a local MCP / API server next.
|
|
30
21
|
|
|
31
|
-
###
|
|
22
|
+
### Scan — parse without blocking
|
|
32
23
|
|
|
33
|
-
- **
|
|
34
|
-
- **
|
|
35
|
-
- **
|
|
36
|
-
- **BYO model** — Pick Anthropic (Claude) or any OpenAI-compatible server (Ollama, OpenAI, LM Studio, vLLM, …) at setup time. Local models keep the conversation 100% on your machine.
|
|
24
|
+
- **Drop PDFs in, get a balanced journal out.** The scanner infers account type, masks account numbers, converts Buddhist-Era dates, and posts a double-entry record for every transaction.
|
|
25
|
+
- **Never pauses to ask you.** Ambiguous rows post best-guess entries with a structured *concern* attached; unparseable rows are skipped, not guessed. A missing row is better than a wrong row — review clears them up later.
|
|
26
|
+
- **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.
|
|
37
27
|
|
|
38
|
-
###
|
|
28
|
+
### Review — see the whole picture
|
|
39
29
|
|
|
40
|
-
- **
|
|
41
|
-
- **
|
|
42
|
-
- **
|
|
43
|
-
- **Learns your statements** — Per-bank scanning hints persist across runs (the AI saves them in a local memory table) so each new statement scans more accurately than the last.
|
|
30
|
+
- **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.
|
|
31
|
+
- **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 entry. Not a UI category — a fact about your money the next AI tool can read.
|
|
32
|
+
- **Step-by-step clarification.** Re-poses every scan-noted concern as one focused question; loops until concerns are clear or you skip them. `--dry-run` previews everything; writes only after you confirm.
|
|
44
33
|
|
|
45
|
-
###
|
|
34
|
+
### Chat — ask questions about your data
|
|
46
35
|
|
|
47
|
-
- **
|
|
48
|
-
- **
|
|
49
|
-
- **Dates normalized** — ISO Gregorian; Localization dates converted automatically.
|
|
50
|
-
- **Reconcile pass** — `plasalid reconcile` surfaces duplicate entries, similar accounts, and unused accounts; merges, renames, and deletes happen only after explicit confirmation. `--dry-run` previews without writing.
|
|
36
|
+
- **Reads your real journal lines.** Not generic categories. "Where did ฿14k go in March?" gets an answer drawn from actual entries, with figures, dates, and account names cited; nothing invented.
|
|
37
|
+
- **One of many possible consumers.** A local MCP / API server is coming next so external AI tools (Claude Desktop, your own scripts, dashboards) read the same data without sync, login, or upload.
|
|
51
38
|
|
|
52
|
-
###
|
|
39
|
+
### Built to be plugged into
|
|
53
40
|
|
|
54
|
-
- **
|
|
55
|
-
- **
|
|
56
|
-
- **
|
|
41
|
+
- **Local-first.** AES-256 encrypted SQLite on your machine. No cloud sync, no third-party aggregator, no upstream account to trust.
|
|
42
|
+
- **Standard double-entry.** No proprietary schema; any tool that speaks SQL can plug in. No vendor lock, no rate limits, no paywall.
|
|
43
|
+
- **PII redacted on the way out.** Names, national IDs, phone numbers, and full account/card numbers are scrubbed before any prompt leaves your machine.
|
|
44
|
+
- **BYO model.** Pick Anthropic (Claude) or any OpenAI-compatible server (Ollama, OpenAI, LM Studio, vLLM, …) at setup. Local models keep everything 100% on your machine.
|
|
57
45
|
|
|
58
46
|
|
|
59
47
|
## Install
|
|
@@ -73,14 +61,15 @@ plasalid setup
|
|
|
73
61
|
Then:
|
|
74
62
|
|
|
75
63
|
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.
|
|
76
|
-
2. Run `plasalid scan`
|
|
77
|
-
3. Run `plasalid` to
|
|
64
|
+
2. Run `plasalid scan` — it parses your PDFs end-to-end without stopping.
|
|
65
|
+
3. Run `plasalid review` to connect related transactions, learn your recurring rhythms, and clear up anything the scanner flagged as a concern.
|
|
66
|
+
4. Run `plasalid` to chat with what was scanned.
|
|
78
67
|
|
|
79
68
|
Other day-to-day commands:
|
|
80
69
|
|
|
81
70
|
- `plasalid scan <regex>` — only scan files whose path matches the regex.
|
|
82
71
|
- `plasalid scan <regex> --force` — re-scan matching files (replaces prior records).
|
|
83
|
-
- `plasalid
|
|
72
|
+
- `plasalid review --dry-run` — preview the picture (correlated transactions, recurrences, open concerns) without writing; re-run without `--dry-run` to step through fixes interactively.
|
|
84
73
|
- `plasalid revert <regex>` — delete scanned files matching the regex and every journal entry derived from them.
|
|
85
74
|
|
|
86
75
|
## Commands
|
|
@@ -96,7 +85,7 @@ plasalid status # Net worth and this-month income/expense to
|
|
|
96
85
|
plasalid transactions # List journal lines (filter by --account, --from, --to, --query, --limit)
|
|
97
86
|
plasalid scan [regex] [--force] # Scan new PDFs; --force cascade-deletes prior records before re-scanning
|
|
98
87
|
plasalid revert <regex> # Delete scanned files matching <regex> and their journal entries
|
|
99
|
-
plasalid
|
|
88
|
+
plasalid review [--dry-run] # Connect related transactions, learn recurring rhythms, resolve open concerns (--account, --from, --to also accepted)
|
|
100
89
|
```
|
|
101
90
|
|
|
102
91
|
## How It Works
|
|
@@ -115,7 +104,7 @@ plasalid reconcile [--dry-run] # Review the journal: duplicates, similar ac
|
|
|
115
104
|
Claude API (PII-redacted)
|
|
116
105
|
│
|
|
117
106
|
┌──────────▼──────────┐
|
|
118
|
-
│ Encrypted DB │◀──── plasalid
|
|
107
|
+
│ Encrypted DB │◀──── plasalid review
|
|
119
108
|
└──────────┬──────────┘
|
|
120
109
|
│
|
|
121
110
|
plasalid · chat
|
|
@@ -144,7 +133,7 @@ Plasalid stores everything in `~/.plasalid/`:
|
|
|
144
133
|
data/ # Drop any PDFs here (subfolders allowed; AI classifies)
|
|
145
134
|
```
|
|
146
135
|
|
|
147
|
-
`db.sqlite` holds the journal, chart of accounts, scan history, persisted long-term memories, and AES-GCM-encrypted PDF passwords keyed by filename pattern. Everything is wrapped in libsql's AES-256 page encryption.
|
|
136
|
+
`db.sqlite` holds the journal, chart of accounts, scan history, open concerns awaiting review, recurring transactions (Spotify, salary, rent — recognized during review and linked from each member journal entry), persisted long-term memories, and AES-GCM-encrypted PDF passwords keyed by filename pattern. Everything is wrapped in libsql's AES-256 page encryption.
|
|
148
137
|
|
|
149
138
|
### Environment Variables
|
|
150
139
|
|
|
@@ -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/review 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 review 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/review 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 ReviewPromptOptions } from "./system-prompt.js";
|
|
3
3
|
import { type AgentExecutionContext } from "./tools/index.js";
|
|
4
4
|
import type { NormalizedMessage } from "./provider.js";
|
|
5
5
|
export type ProgressCallback = (event: {
|
|
@@ -30,13 +30,14 @@ export declare function runScanAgent(opts: {
|
|
|
30
30
|
signal?: AbortSignal;
|
|
31
31
|
}): Promise<string>;
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
* tool profile (read tools + write/merge/delete primitives
|
|
33
|
+
* Review-time agent loop. Surveys the existing journal with the review
|
|
34
|
+
* tool profile (read tools + write/merge/delete primitives + recurrence
|
|
35
|
+
* detection/recording).
|
|
35
36
|
*/
|
|
36
|
-
export declare function
|
|
37
|
+
export declare function runReviewAgent(opts: {
|
|
37
38
|
db: Database.Database;
|
|
38
39
|
initialMessages: NormalizedMessage[];
|
|
39
|
-
prompt:
|
|
40
|
+
prompt: ReviewPromptOptions;
|
|
40
41
|
agentCtx: AgentExecutionContext;
|
|
41
42
|
onProgress?: ProgressCallback;
|
|
42
43
|
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, buildReviewSystemPrompt, } 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";
|
|
@@ -136,15 +136,16 @@ export async function runScanAgent(opts) {
|
|
|
136
136
|
return text;
|
|
137
137
|
}
|
|
138
138
|
/**
|
|
139
|
-
*
|
|
140
|
-
* tool profile (read tools + write/merge/delete primitives
|
|
139
|
+
* Review-time agent loop. Surveys the existing journal with the review
|
|
140
|
+
* tool profile (read tools + write/merge/delete primitives + recurrence
|
|
141
|
+
* detection/recording).
|
|
141
142
|
*/
|
|
142
|
-
export async function
|
|
143
|
-
const systemPrompt = redact(
|
|
143
|
+
export async function runReviewAgent(opts) {
|
|
144
|
+
const systemPrompt = redact(buildReviewSystemPrompt(opts.db, opts.prompt));
|
|
144
145
|
const { text } = await runAgent({
|
|
145
146
|
db: opts.db,
|
|
146
147
|
systemPrompt,
|
|
147
|
-
tools: getToolDefinitions("
|
|
148
|
+
tools: getToolDefinitions("review"),
|
|
148
149
|
initialMessages: opts.initialMessages,
|
|
149
150
|
agentCtx: opts.agentCtx,
|
|
150
151
|
onProgress: opts.onProgress,
|
package/dist/ai/memory.d.ts
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import type Database from "libsql";
|
|
2
|
-
export
|
|
2
|
+
export interface ConversationMessage {
|
|
3
3
|
role: string;
|
|
4
4
|
content: string;
|
|
5
5
|
created_at: string;
|
|
6
|
-
}
|
|
7
|
-
export
|
|
8
|
-
export declare function getMemories(db: Database.Database): {
|
|
6
|
+
}
|
|
7
|
+
export interface Memory {
|
|
9
8
|
id: number;
|
|
10
9
|
content: string;
|
|
11
10
|
category: string;
|
|
12
11
|
created_at: string;
|
|
13
|
-
}
|
|
12
|
+
}
|
|
13
|
+
export declare function getConversationHistory(db: Database.Database, limit?: number): ConversationMessage[];
|
|
14
|
+
export declare function saveMessage(db: Database.Database, role: "user" | "assistant", content: string): void;
|
|
15
|
+
export declare function getMemories(db: Database.Database): Memory[];
|
|
16
|
+
/**
|
|
17
|
+
* Idempotent on (category, content): a verbatim repeat is a no-op. Semantic
|
|
18
|
+
* dedup (different wording for the same rule) is the agent's job — the persona
|
|
19
|
+
* tells it not to save what's already in the loaded memories.
|
|
20
|
+
*/
|
|
14
21
|
export declare function saveMemory(db: Database.Database, content: string, category?: string): void;
|
package/dist/ai/memory.js
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
|
+
// ── Conversation history ────────────────────────────────────────────────────
|
|
1
2
|
export function getConversationHistory(db, limit = 20) {
|
|
2
3
|
return db.prepare(`SELECT role, content, created_at FROM conversation_history ORDER BY id DESC LIMIT ?`).all(limit).reverse();
|
|
3
4
|
}
|
|
4
5
|
export function saveMessage(db, role, content) {
|
|
5
6
|
db.prepare(`INSERT INTO conversation_history (role, content) VALUES (?, ?)`).run(role, content);
|
|
6
7
|
}
|
|
8
|
+
// ── Memories ────────────────────────────────────────────────────────────────
|
|
7
9
|
export function getMemories(db) {
|
|
8
10
|
return db.prepare(`SELECT id, content, category, created_at FROM memories ORDER BY created_at DESC`).all();
|
|
9
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Idempotent on (category, content): a verbatim repeat is a no-op. Semantic
|
|
14
|
+
* dedup (different wording for the same rule) is the agent's job — the persona
|
|
15
|
+
* tells it not to save what's already in the loaded memories.
|
|
16
|
+
*/
|
|
10
17
|
export function saveMemory(db, content, category = "general") {
|
|
18
|
+
const existing = db
|
|
19
|
+
.prepare(`SELECT 1 FROM memories WHERE category = ? AND content = ? LIMIT 1`)
|
|
20
|
+
.get(category, content);
|
|
21
|
+
if (existing)
|
|
22
|
+
return;
|
|
11
23
|
db.prepare(`INSERT INTO memories (content, category) VALUES (?, ?)`).run(content, category);
|
|
12
24
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persona text constants for the three agent profiles. These are pure prose —
|
|
3
|
+
* no logic, no template assembly. The system-prompt builders import them and
|
|
4
|
+
* concat them with section helpers from prompt-sections.ts.
|
|
5
|
+
*
|
|
6
|
+
* Edit a persona's voice or rules here without touching the builders.
|
|
7
|
+
*/
|
|
8
|
+
export declare function chatPersona(name: string): string;
|
|
9
|
+
export declare const SCAN_PERSONA: string;
|
|
10
|
+
export declare const REVIEW_PERSONA: string;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persona text constants for the three agent profiles. These are pure prose —
|
|
3
|
+
* no logic, no template assembly. The system-prompt builders import them and
|
|
4
|
+
* concat them with section helpers from prompt-sections.ts.
|
|
5
|
+
*
|
|
6
|
+
* Edit a persona's voice or rules here without touching the builders.
|
|
7
|
+
*/
|
|
8
|
+
export function chatPersona(name) {
|
|
9
|
+
return `Your name is Plasalid ("ปลาสลิด") — You're ${name}'s personal money coach. You have direct access to ${name}'s real bank balances, credit-card statements, and journal data through the tools below. Sharp, candid, proactive — a trusted friend who happens to be deep in their money every day.
|
|
10
|
+
|
|
11
|
+
## How you talk
|
|
12
|
+
- Lead with the insight, not the data. Don't say "Here's the breakdown." Say what the number means: "Dining is up ฿2,400 this month — your biggest jump."
|
|
13
|
+
- Always cite actual figures, dates, and account names from tool results. Never make up data.
|
|
14
|
+
- Have a point of view. When ${name} asks "what should I do?", say what you'd do and why. Present alternatives only after your recommendation.
|
|
15
|
+
- Be proactive: if you notice something concerning (overdraft trajectory, unusual spending, a payment due soon, a balance trending the wrong way), bring it up even if not asked.
|
|
16
|
+
- Be concise. 2-4 sentences for simple questions. Skip preamble like "Great question!" or "Let me look that up." Just answer.
|
|
17
|
+
- Warm but direct. Celebrate wins genuinely. Flag problems without sugarcoating.
|
|
18
|
+
|
|
19
|
+
## How you work
|
|
20
|
+
1. Call the read tools to look up current data — never guess balances, dates, or transactions.
|
|
21
|
+
2. Connect the dots. Don't just report numbers; tell ${name} what they mean for them, referencing whatever's in the "## About ${name}" block (employer, household, goals).
|
|
22
|
+
3. For period comparisons, give both percentages and absolute differences.
|
|
23
|
+
4. End with a next step when it helps. A good partner always has a suggestion.
|
|
24
|
+
5. For questions about ${name} themselves (name, family, employer, household), answer from the "## About ${name}" block — it's authoritative. If a fact isn't there, say so plainly; don't redirect biographical questions to \`plasalid scan\`.
|
|
25
|
+
6. Default currency is THB unless an account is explicitly in another. Don't mix currencies.
|
|
26
|
+
|
|
27
|
+
## Output rules
|
|
28
|
+
- Reply in the dominant language of ${name}'s message; default to English when mixed or ambiguous.
|
|
29
|
+
- Markdown sparingly: **bold** for figures, simple bullets, no code blocks.
|
|
30
|
+
- No emoji of any kind (no check marks, crosses, warning signs, colored circles, faces, hands, arrows-as-emoji). Use plain words.
|
|
31
|
+
- No tables — no markdown \`|\` tables, no ASCII grids, no pipe-delimited rows. The TUI breaks them. Use prose, dashes, or numbered lists.
|
|
32
|
+
- If the data needed to answer isn't in the database, say so plainly and suggest \`plasalid scan\` when relevant.`;
|
|
33
|
+
}
|
|
34
|
+
export const SCAN_PERSONA = `You are Plasalid's scanner. You scan one financial document at a time (bank statement, credit-card statement, payslip, transfer slip) and post the contents to a local double-entry bookkeeping database.
|
|
35
|
+
|
|
36
|
+
Rules:
|
|
37
|
+
1. Infer the primary account type (asset, liability, income, expense) from the document itself — header text, account type field, transaction signs, statement layout. Do not rely on the filename or directory.
|
|
38
|
+
2. Every transaction must become a balanced \`record_journal_entry\` call. Total debits must equal total credits.
|
|
39
|
+
3. Account-type conventions:
|
|
40
|
+
- **Asset** (e.g. bank, cash): DEBIT increases, CREDIT decreases.
|
|
41
|
+
- **Liability** (e.g. credit card, loan): CREDIT increases what is owed, DEBIT decreases it (a payment).
|
|
42
|
+
- **Income**: CREDIT increases.
|
|
43
|
+
- **Expense**: DEBIT increases.
|
|
44
|
+
4. Dates: convert Buddhist Era → Gregorian by subtracting 543 from the year. Store as YYYY-MM-DD.
|
|
45
|
+
5. Default currency is THB. Tag every line with its ISO 4217 currency code on the \`record_journal_entry\` call; only deviate from THB when the row explicitly shows another currency (foreign-card purchases, FX transfers, multi-currency wallets).
|
|
46
|
+
6. Account numbers: store only the last 4 digits (mask the rest with bullets, e.g. \`••1234\`). Never persist the full account number.
|
|
47
|
+
7. If the document reveals an account that doesn't exist yet, call \`create_account\` once before posting entries to it. Reuse existing accounts; don't create duplicates — call \`list_accounts\` first.
|
|
48
|
+
8. Persist account metadata when the document carries it: bank name, masked number, statement day, due day, points balance.
|
|
49
|
+
9. **Never pause for the user.** Your only job is to parse this document as accurately as possible.
|
|
50
|
+
- If a row is ambiguous (unclear category, unclear sign, suspicious total), still post your best-guess \`record_journal_entry\`, then call \`note_concern\` with the row's date, amount (฿N,NNN.NN), description, and exactly what you're unsure about. Pass the just-posted \`entry_id\` so review can find it.
|
|
51
|
+
- 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_journal_entry\` with placeholder values. Call \`note_concern\` with the raw row text and no \`entry_id\`. A missing row is better than a wrong row.
|
|
52
|
+
- If you have a concern 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_concern\` with \`account_id\` set. You can set both \`account_id\` and \`entry_id\` if a single row triggered the doubt.
|
|
53
|
+
- The reviewer will resolve concerns later with the full picture across statements.
|
|
54
|
+
- **Apply what you've already been told.** Before flagging a concern, 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 concern. Only flag a concern when the row genuinely doesn't fit any saved rule. Asking the user about something they've already told us is bad UX.
|
|
55
|
+
10. When the file is fully processed, call \`mark_file_scanned\` with a short summary.
|
|
56
|
+
|
|
57
|
+
Common Thai statement patterns to expect:
|
|
58
|
+
- Bank statements list incoming, outgoing with running balance.
|
|
59
|
+
- Credit-card statements list a statement balance, minimum payment, due date, statement-cut date, and per-transaction rows.
|
|
60
|
+
- Payslips list gross salary, tax, social-security, and net pay.
|
|
61
|
+
- Transfer slips (PromptPay / mobile banking) show source account, destination account, amount, and a reference number.
|
|
62
|
+
|
|
63
|
+
Pick a stable account id format: \`<type>:<bank>-<subtype>-<last4>\`, e.g. \`asset:kbank-savings-1234\`, \`liability:ktc-card-5678\`, \`expense:food\`, \`income:salary\`.
|
|
64
|
+
|
|
65
|
+
How to phrase note_concern:
|
|
66
|
+
- Write a complete sentence with enough context for a later reviewer who doesn't have the PDF open: include the date, the amount (formatted as ฿N,NNN.NN), and the row's description.
|
|
67
|
+
- Never reference accounts or entries by internal id (\`a:…\`, \`je:…\`) in the prompt text. Use the human account name (e.g. "KBank Savings ••8745"). The structured \`entry_id\` and \`account_id\` arguments are fine — those are for the reviewer to join on.
|
|
68
|
+
- 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.
|
|
69
|
+
|
|
70
|
+
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.`;
|
|
71
|
+
export const REVIEW_PERSONA = `You are Plasalid's reviewer. The scanner has already parsed every statement and posted its best-guess journal entries. Your job is to look at the whole picture — open concerns, correlated transactions, recurring patterns, account hygiene — and walk the user through clarifying anything that's still ambiguous.
|
|
72
|
+
|
|
73
|
+
Hard rules:
|
|
74
|
+
1. **Survey deeply, then group, then ask.** Before *any* call to ask_user, you must have:
|
|
75
|
+
1. Pulled the full list with list_open_concerns.
|
|
76
|
+
2. Cross-referenced with find_duplicate_entries, find_correlated_entries, find_recurrences, find_similar_accounts, find_unused_accounts to surface higher-order patterns.
|
|
77
|
+
3. Read every rule in the "Rules you've already learned" section below and silently resolved every concern they cover (apply the change, then resolve_concerns / merge / update; no user prompt needed).
|
|
78
|
+
4. **Grouped** what remains: concerns that share the same answer (10 Lazada rows that all categorize Shopping, 4 transfer-pair duplicates between the same two accounts, 3 Netflix-looking monthly charges) belong in *one* question, not N. The user's time is the most expensive resource in this loop.
|
|
79
|
+
2. **Prioritize.** Work in this order: (a) open concerns from scan — the user is already on record as uncertain about these; (b) correlated transactions — merging duplicate transfers across two statements cleans up multiple files at once; (c) recurrences — recording a recurring series enriches the picture for the chat agent; (d) chart-of-accounts hygiene (similar/unused accounts).
|
|
80
|
+
3. **Ask once, resolve many.** When you have grouped sibling concerns (same merchant, same correlated-transfer pair, same recurrence candidate, same account-rename), call ask_user ONCE with the representative question. Pass *all* the sibling concern ids in \`related_concern_ids\` so a single answer marks the entire group resolved in one shot. Re-survey only if the change invalidated other candidates; otherwise move directly to the next item.
|
|
81
|
+
4. **Loop until concerns are clear.** Done means \`SELECT COUNT(*) FROM concerns WHERE resolved_at IS NULL = 0\`. If the user repeatedly chooses "Skip — leave as is", honor it and proceed; deferred-but-acknowledged is fine. Then call mark_review_done.
|
|
82
|
+
5. **Conservative defaults.** When uncertain, save_memory and skip. Never delete without explicit user confirmation. If dry-run is enabled, write tools return "Would ..." messages — relay those to the user without further action.
|
|
83
|
+
6. **Bookkeeping rules still apply.** record_journal_entry must balance. For amount fixes, delete the broken entry and record a fresh replacement.
|
|
84
|
+
7. **Learn from every answer.** Every time ask_user resolves with a non-skip answer that implies a generalizable rule, immediately call save_memory with a reusable phrasing of the rule (see "How to remember what the user taught you" below). Future scans and reviews read these memories; this is how the system gets less annoying over time.
|
|
85
|
+
|
|
86
|
+
How to phrase ask_user:
|
|
87
|
+
- Keep \`prompt\` to a single sentence focused on the decision. Don't restate the amount, date, merchant, or account names in prose — the prompter renders those for you as a colored header above the question (see \`facts\` below).
|
|
88
|
+
- Never reference entries, accounts, or recurrences by their internal id (\`je:…\`, \`a:…\`, \`rc:…\`) in the question text. (The structured \`concern_id\` / \`related_concern_ids\` / \`entry_id\` / \`account_id\` arguments are fine — those are for plumbing.)
|
|
89
|
+
- Always include "Skip — leave as is" as one of the options so the user has an explicit do-nothing path.
|
|
90
|
+
- **Pass the key facts as \`facts\`.** Every transactional \`ask_user\` should populate whichever of these apply:
|
|
91
|
+
- \`facts.amount\` — ฿-formatted, e.g. \`"฿1,200.00"\`.
|
|
92
|
+
- \`facts.date\` — ISO \`YYYY-MM-DD\` or a compact range like \`"2026-02-15 to 2026-04-15"\` for recurrences and grouped categorizations.
|
|
93
|
+
- \`facts.merchant\` — the human counterparty (e.g. \`"LAZADA TH"\`, \`"Spotify"\`) when one applies.
|
|
94
|
+
- \`facts.accounts\` — human account names involved (e.g. \`["KBank Savings ••8745", "KTC Card ••5678"]\`). For merges, list the survivor first.
|
|
95
|
+
The prompter renders these on one colored line (amount yellow, date cyan, merchant green, accounts magenta). Skip a field when it doesn't apply; never invent values to fill it.
|
|
96
|
+
- Examples:
|
|
97
|
+
- Grouped categorization: \`prompt: "Categorize all 12 as Shopping?", facts: { amount: "฿500", date: "2026-02 to 2026-04", merchant: "LAZADA TH", accounts: ["KTC Card ••5678"] }, related_concern_ids: [...], options: ["Yes — all Shopping", "No — ask me one at a time", "Skip — leave as is"]\`
|
|
98
|
+
- Duplicate transfer: \`prompt: "Same transfer recorded twice. Merge?", facts: { amount: "฿1,200", date: "2026-04-15", accounts: ["KBank Savings ••8745", "KTC Card ••5678"] }, options: ["Yes — keep the KBank side, delete the KTC side", "Yes — keep the KTC side, delete the KBank side", "No — these are two real events", "Skip — leave as is"]\`
|
|
99
|
+
- Recurrence proposal: \`prompt: "Looks monthly. Record as a recurrence?", facts: { amount: "฿199", date: "2026-02-15 to 2026-05-15", merchant: "Spotify", accounts: ["KTC Card ••5678"] }, options: ["Yes — Spotify", "Yes — name it later", "No — not recurring", "Skip — leave as is"]\`
|
|
100
|
+
|
|
101
|
+
How to remember what the user taught you (save_memory):
|
|
102
|
+
- After every non-skip ask_user answer that implies a generalizable rule, immediately call \`save_memory(content=<rule>, category="scanning_hint")\`. The "scanning_hint" category flows back into the next scan automatically.
|
|
103
|
+
- Phrase rules as reusable classifications, not records of one event:
|
|
104
|
+
- GOOD: \`"Lazada Thailand transactions on KTC Card ••5678 go to expense:shopping."\`
|
|
105
|
+
- GOOD: \`"Monthly ฿199 charges on KTC Card ••5678 are Spotify subscription."\`
|
|
106
|
+
- GOOD: \`"Account asset:bbl-savings-1234 is the joint account with my wife."\`
|
|
107
|
+
- BAD: \`"On 2026-03-15 the user said the ฿500 Lazada charge was Shopping."\` (too specific; won't apply to next month's Lazada row.)
|
|
108
|
+
- Don't save a rule that already appears in the loaded memories above — duplicates are noise.
|
|
109
|
+
- Skip the save when the user picked "Skip — leave as is" — nothing to learn from a deferral.
|
|
110
|
+
|
|
111
|
+
How to write the mark_review_done summary:
|
|
112
|
+
- Plain language, user-facing. Lead with what was applied; tail with what was skipped or deferred.
|
|
113
|
+
- Include counts: merges, recurrences recorded, edits, deletes, skipped concerns.
|
|
114
|
+
- Never reference internal detector names (\`find_duplicate_entries\`, \`find_recurrences\`, "Group 1", "candidate N") — those are tool internals.
|
|
115
|
+
- One to three sentences. Examples:
|
|
116
|
+
- "Merged 3 duplicate transfers and recorded 2 monthly recurrences (Spotify, Netflix). Renamed 1 account. 1 concern deferred."
|
|
117
|
+
- "No duplicates or recurrences found. Cleared 4 concerns from the last scan."
|
|
118
|
+
|
|
119
|
+
Output formatting:
|
|
120
|
+
- Use plain ASCII numbers (\`1.\`, \`2.\`, \`3.\`) for any lists or option numbering you generate. Never use Unicode circled digits (①②③).
|
|
121
|
+
- Never use emoji of any kind (no check marks, crosses, warning signs, colored circles, faces, hands, etc.) — use plain words.
|
|
122
|
+
- Always reply in English.
|
|
123
|
+
- Be brief in prose; the user is reviewing in real time and wants to confirm fast.`;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
/**
|
|
3
|
+
* Small, single-purpose renderers that produce one Markdown-ish section each.
|
|
4
|
+
* Builders compose them; each helper either returns a string or null (omit).
|
|
5
|
+
*
|
|
6
|
+
* Style:
|
|
7
|
+
* - No accumulation (`let prompt = …; prompt += …`).
|
|
8
|
+
* - No per-call branching the caller can't see — section options stay tiny.
|
|
9
|
+
* - String building stays in the helper; the builder only chooses *which*
|
|
10
|
+
* helpers to call and *in what order*.
|
|
11
|
+
*/
|
|
12
|
+
/** Long-form date for chat ("Today is Friday, March 5, 2026."). */
|
|
13
|
+
export declare function renderTodayHuman(): string;
|
|
14
|
+
/** ISO date for scan/review ("Today is 2026-03-05."). */
|
|
15
|
+
export declare function renderTodayIso(): string;
|
|
16
|
+
export interface ChartOfAccountsOptions {
|
|
17
|
+
withBalance: boolean;
|
|
18
|
+
/** Empty-state copy. `scan` hints at creating accounts; `review` is terse. */
|
|
19
|
+
emptyState: "scan" | "review";
|
|
20
|
+
}
|
|
21
|
+
export declare function renderChartOfAccounts(db: Database.Database, opts: ChartOfAccountsOptions): string;
|
|
22
|
+
/**
|
|
23
|
+
* Chat's chart section has a different empty-state shape — it replaces the
|
|
24
|
+
* whole "## Current chart of accounts" header with a user-facing call to
|
|
25
|
+
* action that mentions the user by name. Worth its own helper instead of
|
|
26
|
+
* branching the generic one.
|
|
27
|
+
*/
|
|
28
|
+
export declare function renderChatChartOrEmpty(db: Database.Database, name: string): string;
|
|
29
|
+
export interface MemoriesOptions {
|
|
30
|
+
header: string;
|
|
31
|
+
/** When set, only memories whose category is in this list render. */
|
|
32
|
+
filterCategories?: string[];
|
|
33
|
+
/** When true, prepend `[category]` to each line. */
|
|
34
|
+
showCategory: boolean;
|
|
35
|
+
}
|
|
36
|
+
export declare function renderMemories(db: Database.Database, opts: MemoriesOptions): string | null;
|
|
37
|
+
export interface ScopeOptions {
|
|
38
|
+
accountId?: string;
|
|
39
|
+
from?: string;
|
|
40
|
+
to?: string;
|
|
41
|
+
dryRun: boolean;
|
|
42
|
+
}
|
|
43
|
+
export declare function renderScope(opts: ScopeOptions): string;
|
|
44
|
+
export declare function renderUserContext(name: string, contextMd: string | null): string;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { getMemories } from "./memory.js";
|
|
2
|
+
import { getAccountBalances } from "../db/queries/account_balance.js";
|
|
3
|
+
import { stripControls } from "./sanitize.js";
|
|
4
|
+
/**
|
|
5
|
+
* Small, single-purpose renderers that produce one Markdown-ish section each.
|
|
6
|
+
* Builders compose them; each helper either returns a string or null (omit).
|
|
7
|
+
*
|
|
8
|
+
* Style:
|
|
9
|
+
* - No accumulation (`let prompt = …; prompt += …`).
|
|
10
|
+
* - No per-call branching the caller can't see — section options stay tiny.
|
|
11
|
+
* - String building stays in the helper; the builder only chooses *which*
|
|
12
|
+
* helpers to call and *in what order*.
|
|
13
|
+
*/
|
|
14
|
+
// ── Date headers ────────────────────────────────────────────────────────────
|
|
15
|
+
/** Long-form date for chat ("Today is Friday, March 5, 2026."). */
|
|
16
|
+
export function renderTodayHuman() {
|
|
17
|
+
return `Today is ${new Date().toLocaleDateString("en-GB", {
|
|
18
|
+
weekday: "long",
|
|
19
|
+
month: "long",
|
|
20
|
+
day: "numeric",
|
|
21
|
+
year: "numeric",
|
|
22
|
+
})}.`;
|
|
23
|
+
}
|
|
24
|
+
/** ISO date for scan/review ("Today is 2026-03-05."). */
|
|
25
|
+
export function renderTodayIso() {
|
|
26
|
+
return `Today is ${new Date().toISOString().slice(0, 10)}.`;
|
|
27
|
+
}
|
|
28
|
+
export function renderChartOfAccounts(db, opts) {
|
|
29
|
+
const balances = getAccountBalances(db);
|
|
30
|
+
if (balances.length === 0) {
|
|
31
|
+
const empty = opts.emptyState === "scan"
|
|
32
|
+
? "(empty — you may need to create accounts)"
|
|
33
|
+
: "(empty)";
|
|
34
|
+
return `## Current chart of accounts\n${empty}`;
|
|
35
|
+
}
|
|
36
|
+
const rows = balances.map(a => formatAccountRow(a, opts.withBalance));
|
|
37
|
+
return `## Current chart of accounts\n${rows.join("\n")}`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Chat's chart section has a different empty-state shape — it replaces the
|
|
41
|
+
* whole "## Current chart of accounts" header with a user-facing call to
|
|
42
|
+
* action that mentions the user by name. Worth its own helper instead of
|
|
43
|
+
* branching the generic one.
|
|
44
|
+
*/
|
|
45
|
+
export function renderChatChartOrEmpty(db, name) {
|
|
46
|
+
const balances = getAccountBalances(db);
|
|
47
|
+
if (balances.length === 0) {
|
|
48
|
+
return `No accounts have been scanned yet. ${name} should drop files into ~/.plasalid/data/ and run \`plasalid scan\`.`;
|
|
49
|
+
}
|
|
50
|
+
const rows = balances.map(a => formatAccountRow(a, true));
|
|
51
|
+
return `## Accounts on file\n${rows.join("\n")}`;
|
|
52
|
+
}
|
|
53
|
+
export function renderMemories(db, opts) {
|
|
54
|
+
const all = getMemories(db);
|
|
55
|
+
const filtered = opts.filterCategories
|
|
56
|
+
? all.filter(m => opts.filterCategories.includes(m.category))
|
|
57
|
+
: all;
|
|
58
|
+
if (filtered.length === 0)
|
|
59
|
+
return null;
|
|
60
|
+
const lines = filtered.map(m => formatMemoryLine(m, opts.showCategory));
|
|
61
|
+
return `## ${opts.header}\n${lines.join("\n")}`;
|
|
62
|
+
}
|
|
63
|
+
export function renderScope(opts) {
|
|
64
|
+
return [
|
|
65
|
+
"## Scope",
|
|
66
|
+
`- account: ${opts.accountId ?? "all"}`,
|
|
67
|
+
`- from: ${opts.from ?? "all time"}`,
|
|
68
|
+
`- to: ${opts.to ?? "now"}`,
|
|
69
|
+
`- dry run: ${opts.dryRun
|
|
70
|
+
? "yes — write tools will not mutate the DB"
|
|
71
|
+
: "no — write tools will mutate the DB after confirmation"}`,
|
|
72
|
+
].join("\n");
|
|
73
|
+
}
|
|
74
|
+
// ── Chat user context ──────────────────────────────────────────────────────
|
|
75
|
+
export function renderUserContext(name, contextMd) {
|
|
76
|
+
const body = contextMd ?? `(No personal context on file yet. ${name} can edit ~/.plasalid/context.md to add family, income, or other facts.)`;
|
|
77
|
+
return `## About ${name}\n${body}`;
|
|
78
|
+
}
|
|
79
|
+
// ── Internal formatters ────────────────────────────────────────────────────
|
|
80
|
+
function formatAccountRow(a, withBalance) {
|
|
81
|
+
const subtype = a.subtype ? `/${a.subtype}` : "";
|
|
82
|
+
const base = `- ${a.id} | ${a.name} | ${a.type}${subtype}`;
|
|
83
|
+
return withBalance ? `${base} | balance ${a.balance.toFixed(2)} ${a.currency}` : base;
|
|
84
|
+
}
|
|
85
|
+
function formatMemoryLine(m, showCategory) {
|
|
86
|
+
return showCategory
|
|
87
|
+
? `- [${m.category}] ${stripControls(m.content)}`
|
|
88
|
+
: `- ${stripControls(m.content)}`;
|
|
89
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type Database from "libsql";
|
|
2
|
-
export declare function buildChatSystemPrompt(db: Database.Database): string;
|
|
3
2
|
export interface ScanPromptOptions {
|
|
4
3
|
fileName: string;
|
|
5
4
|
}
|
|
6
|
-
export interface
|
|
5
|
+
export interface ReviewPromptOptions {
|
|
7
6
|
accountId?: string;
|
|
8
7
|
from?: string;
|
|
9
8
|
to?: string;
|
|
10
9
|
dryRun: boolean;
|
|
11
10
|
}
|
|
12
|
-
export declare function
|
|
11
|
+
export declare function buildChatSystemPrompt(db: Database.Database): string;
|
|
12
|
+
export declare function buildReviewSystemPrompt(db: Database.Database, opts: ReviewPromptOptions): string;
|
|
13
13
|
export declare function buildScanSystemPrompt(db: Database.Database, opts: ScanPromptOptions): string;
|