plasalid 0.3.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +33 -43
  2. package/dist/accounts/taxonomy.d.ts +1 -1
  3. package/dist/accounts/taxonomy.js +2 -2
  4. package/dist/ai/agent.d.ts +19 -5
  5. package/dist/ai/agent.js +26 -6
  6. package/dist/ai/memory.d.ts +14 -5
  7. package/dist/ai/memory.js +12 -0
  8. package/dist/ai/personas.d.ts +11 -0
  9. package/dist/ai/personas.js +193 -0
  10. package/dist/ai/prompt-sections.d.ts +49 -0
  11. package/dist/ai/prompt-sections.js +107 -0
  12. package/dist/ai/system-prompt.d.ts +14 -3
  13. package/dist/ai/system-prompt.js +59 -165
  14. package/dist/ai/thinking.js +1 -1
  15. package/dist/ai/tools/common.js +2 -5
  16. package/dist/ai/tools/index.js +32 -7
  17. package/dist/ai/tools/ingest.d.ts +3 -1
  18. package/dist/ai/tools/ingest.js +372 -124
  19. package/dist/ai/tools/merchants.d.ts +2 -0
  20. package/dist/ai/tools/merchants.js +117 -0
  21. package/dist/ai/tools/read.js +57 -24
  22. package/dist/ai/tools/record.d.ts +2 -0
  23. package/dist/ai/tools/record.js +188 -0
  24. package/dist/ai/tools/review.d.ts +2 -0
  25. package/dist/ai/tools/review.js +359 -0
  26. package/dist/ai/tools/scan.js +5 -3
  27. package/dist/ai/tools/types.d.ts +33 -4
  28. package/dist/cli/commands/accounts.js +33 -25
  29. package/dist/cli/commands/record.d.ts +4 -0
  30. package/dist/cli/commands/record.js +119 -0
  31. package/dist/cli/commands/revert.js +1 -1
  32. package/dist/cli/commands/review.d.ts +2 -0
  33. package/dist/cli/commands/review.js +15 -0
  34. package/dist/cli/commands/scan.d.ts +4 -2
  35. package/dist/cli/commands/scan.js +143 -19
  36. package/dist/cli/commands/status.js +6 -9
  37. package/dist/cli/commands/transactions.js +36 -41
  38. package/dist/cli/format.d.ts +2 -0
  39. package/dist/cli/format.js +7 -2
  40. package/dist/cli/index.js +28 -13
  41. package/dist/cli/ink/scan_dashboard.d.ts +38 -0
  42. package/dist/cli/ink/scan_dashboard.js +62 -0
  43. package/dist/cli/setup.d.ts +0 -1
  44. package/dist/cli/setup.js +2 -8
  45. package/dist/cli/ux.d.ts +2 -1
  46. package/dist/cli/ux.js +36 -2
  47. package/dist/currency.d.ts +3 -0
  48. package/dist/currency.js +12 -1
  49. package/dist/db/queries/account_balance.d.ts +84 -4
  50. package/dist/db/queries/account_balance.js +239 -20
  51. package/dist/db/queries/action_log.d.ts +29 -0
  52. package/dist/db/queries/action_log.js +27 -0
  53. package/dist/db/queries/concerns.d.ts +50 -0
  54. package/dist/db/queries/concerns.js +91 -0
  55. package/dist/db/queries/journal.d.ts +75 -8
  56. package/dist/db/queries/journal.js +131 -19
  57. package/dist/db/queries/merchants.d.ts +42 -0
  58. package/dist/db/queries/merchants.js +120 -0
  59. package/dist/db/queries/recurrences.d.ts +33 -0
  60. package/dist/db/queries/recurrences.js +128 -0
  61. package/dist/db/queries/search.d.ts +5 -4
  62. package/dist/db/queries/search.js +16 -12
  63. package/dist/db/queries/transactions.d.ts +167 -0
  64. package/dist/db/queries/transactions.js +320 -0
  65. package/dist/db/schema.js +74 -9
  66. package/dist/reviewer/pipeline.d.ts +18 -0
  67. package/dist/reviewer/pipeline.js +46 -0
  68. package/dist/reviewer/prompts.d.ts +12 -0
  69. package/dist/reviewer/prompts.js +22 -0
  70. package/dist/scanner/account_mutex.d.ts +1 -0
  71. package/dist/scanner/account_mutex.js +16 -0
  72. package/dist/scanner/buffer.d.ts +51 -0
  73. package/dist/scanner/buffer.js +63 -0
  74. package/dist/scanner/concurrency.d.ts +14 -0
  75. package/dist/scanner/concurrency.js +31 -0
  76. package/dist/scanner/decrypt_queue.d.ts +57 -0
  77. package/dist/scanner/decrypt_queue.js +96 -0
  78. package/dist/scanner/pipeline.d.ts +47 -18
  79. package/dist/scanner/pipeline.js +247 -97
  80. package/dist/scanner/prompts.js +3 -3
  81. package/package.json +2 -2
package/README.md CHANGED
@@ -1,59 +1,47 @@
1
1
  <h1 align="center">Plasalid</h1>
2
2
 
3
3
  <p align="center">
4
- <strong>Talk to your money</strong>
4
+ <strong>The Harness for Personal Finance</strong>
5
5
  </p>
6
6
 
7
7
  <p align="center">
8
- A local-first AI that reads every line of your transactions and coaches you the best move.
8
+ Turn the financial documents you already receive into a structured, AI-readable context.
9
9
  </p>
10
10
 
11
11
 
12
12
  <br />
13
13
 
14
- Plasalid lets you actually *talk* to your money. Drop your bank and credit-card statement PDFs into a folder. Plasalid scans every transaction, every balance, every late fee into a double-entry database on your own machine. Then you chat with it an AI that's read every line and tells you, sharply and proactively, what's going on. Local, private, yours.
15
-
16
- Plasalid exists because in markets like Thailand there's no Plaid. In the US, a single API gives apps a live view of every balance and transaction across all your accounts — one complete picture of your money. In Thailand, knowing where you stand means logging into five different bank apps in sequence one by one. Most people just don't bother. And most people can't afford a financial advisor to do it for them either. The result is people fly blind with their own money — and grow careless with it. Bills slip past, small leaks compound, and the first real look tends to come after something has already gone wrong.
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.
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 goes further than a local Plaid — it's a harness layer: drop your bank and credit-card statement PDFs into a folder and Plasalid parses every transaction, balance into a double-entry database on your own machine, ready for any AI to plug into. No cloud aggregator. No upstream account to trust. One source of truth for every account.
21
15
 
22
16
  ## Features
23
17
 
24
- ### Your personal money coach
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.
18
+ Plasalid is a chain of three stages: **Scan → Review → 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.
30
19
 
31
- ### A data harness AI can plug into
20
+ ### Scan parse without blocking
32
21
 
33
- - **The missing aggregator** — In markets without Plaid, there's no bank API that easy to access. Plasalid turns the documents you already receive into a database that machine can read, so the data layer stops being the blocker.
34
- - **Composable substrate** — Plasalid's local SQLite is plain, queryable double-entry data. Any tool that can read SQLite Claude Code, MCP servers, your own scripts, dashboards can build automations, alerts, exporters, or personalized analyses on top, with no further integration work.
35
- - **No vendor lock, no rate limits** Standard accounts and journal lines, your encryption key, your machine. Nothing to revoke, throttle, or paywall.
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.
22
+ - **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.
23
+ - **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.
24
+ - **Never pauses to ask you.** Ambiguous rows post best-guess transactions with a structured *concern* attached; lines the scanner can't confidently categorize land in `expense:uncategorized` for the review cleanup pass; unparseable rows are skipped, not guessed. A missing row is better than a wrong row — review clears them up later.
25
+ - **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
26
 
38
- ### Drop documents in, get structured data out
27
+ ### Review see the whole picture
39
28
 
40
- - **Encrypted PDFs handled inline** Statement password-protected? Plasalid prompts you once, then remembers the password (encrypted at rest) under a filename pattern so the next month's statement unlocks silently.
41
- - **Asks instead of guessing** Ambiguous row? The scanner pauses and prompts you.
42
- - **Idempotent scan** Files are hashed; re-running `plasalid scan` skips what it already scanned. `--force` cascade-deletes prior records before re-scanning.
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.
29
+ - **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.
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 transaction. Not a UI category — a structured fact any AI consumer 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
- ### Correctness, not vibes
34
+ ### Chat ask questions about your data
46
35
 
47
- - **Double-entry bookkeeping** Every transaction balances enforced by standard double-entry accounting.
48
- - **Account metadata preserved** Bank, masked number, statement day, due day, points.
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 transactions and postings.** Not generic categories. "Where did ฿14k go in March?" gets an answer drawn from actual postings against real expense categories, with figures, dates, account names, and merchants 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
- ### Your data never leaves your machine
39
+ ### Built to be plugged into
53
40
 
54
- - **Encrypted local database** All data stays on your machine in an AES-256 encrypted SQLite database.
55
- - **PII masking** Names, national IDs, phones, full account/card numbers scrubbed before anything reaches the AI.
56
- - **No telemetry. No analytics.** Only outbound traffic is to your configured AI provider.
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,15 +61,16 @@ 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` and answer any clarifying questions inline.
77
- 3. Run `plasalid` to chat with what was scanned.
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 reconcile --dry-run` — periodically surface duplicate entries and similar accounts; re-run without `--dry-run` to apply fixes interactively.
84
- - `plasalid revert <regex>` — delete scanned files matching the regex and every journal entry derived from them.
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.
73
+ - `plasalid revert <regex>` — delete scanned files matching the regex and every transaction derived from them.
85
74
 
86
75
  ## Commands
87
76
 
@@ -93,10 +82,11 @@ plasalid setup # Configure API key, encryption, and data di
93
82
  plasalid data # Open the Plasalid data folder in your OS file explorer
94
83
  plasalid accounts # Show the chart of accounts with balances
95
84
  plasalid status # Net worth and this-month income/expense totals
96
- plasalid transactions # List journal lines (filter by --account, --from, --to, --query, --limit)
85
+ plasalid transactions # List transactions and their postings (filter by --account, --from, --to, --query, --limit)
86
+ plasalid record <utterance> # Add a manual transaction, account, balance, or merchant from a plain-language line
97
87
  plasalid scan [regex] [--force] # Scan new PDFs; --force cascade-deletes prior records before re-scanning
98
- plasalid revert <regex> # Delete scanned files matching <regex> and their journal entries
99
- plasalid reconcile [--dry-run] # Review the journal: duplicates, similar accounts, unused accounts (--account, --from, --to also accepted)
88
+ plasalid revert <regex> # Delete scanned files matching <regex> and their transactions
89
+ plasalid review [--dry-run] # Connect related transactions, learn recurring rhythms, resolve open concerns (--account, --from, --to also accepted)
100
90
  ```
101
91
 
102
92
  ## How It Works
@@ -115,7 +105,7 @@ plasalid reconcile [--dry-run] # Review the journal: duplicates, similar ac
115
105
  Claude API (PII-redacted)
116
106
 
117
107
  ┌──────────▼──────────┐
118
- │ Encrypted DB │◀──── plasalid reconcile
108
+ │ Encrypted DB │◀──── plasalid review
119
109
  └──────────┬──────────┘
120
110
 
121
111
  plasalid · chat
@@ -144,7 +134,7 @@ Plasalid stores everything in `~/.plasalid/`:
144
134
  data/ # Drop any PDFs here (subfolders allowed; AI classifies)
145
135
  ```
146
136
 
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.
137
+ `db.sqlite` holds the three-layer ledger (hierarchical accounts, deduplicated merchants with learned default categories, transactions and postings), scan history, open concerns awaiting review, recurring transactions (Spotify, salary, rent — recognized during review 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.
148
138
 
149
139
  ### Environment Variables
150
140
 
@@ -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/reconcile system prompts.
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 reconciliation).",
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/reconcile system prompts.
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
  */
@@ -1,5 +1,5 @@
1
1
  import type Database from "libsql";
2
- import { type ScanPromptOptions, type ReconcilePromptOptions } from "./system-prompt.js";
2
+ import { type ScanPromptOptions, type ReviewPromptOptions, 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 type ProgressCallback = (event: {
@@ -30,13 +30,27 @@ export declare function runScanAgent(opts: {
30
30
  signal?: AbortSignal;
31
31
  }): Promise<string>;
32
32
  /**
33
- * Reconcile-time agent loop. Walks the existing journal with the reconcile
34
- * tool profile (read tools + write/merge/delete primitives).
33
+ * Record-time agent loop. Takes one natural-language utterance and walks the
34
+ * record tool profile (read tools + account/entry writers + adjust_balance +
35
+ * clarify). Single-shot — does not persist conversation history.
35
36
  */
36
- export declare function runReconcileAgent(opts: {
37
+ export declare function runRecordAgent(opts: {
37
38
  db: Database.Database;
38
39
  initialMessages: NormalizedMessage[];
39
- prompt: ReconcilePromptOptions;
40
+ prompt: RecordPromptOptions;
41
+ agentCtx: AgentExecutionContext;
42
+ onProgress?: ProgressCallback;
43
+ signal?: AbortSignal;
44
+ }): Promise<string>;
45
+ /**
46
+ * Review-time agent loop. Surveys the existing transactions with the review
47
+ * tool profile (read tools + write/merge/delete primitives + recurrence
48
+ * detection/recording).
49
+ */
50
+ export declare function runReviewAgent(opts: {
51
+ db: Database.Database;
52
+ initialMessages: NormalizedMessage[];
53
+ prompt: ReviewPromptOptions;
40
54
  agentCtx: AgentExecutionContext;
41
55
  onProgress?: ProgressCallback;
42
56
  signal?: AbortSignal;
package/dist/ai/agent.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { config } from "../config.js";
2
- import { buildChatSystemPrompt, buildScanSystemPrompt, buildReconcileSystemPrompt, } from "./system-prompt.js";
2
+ import { buildChatSystemPrompt, buildScanSystemPrompt, buildReviewSystemPrompt, 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";
@@ -136,15 +136,35 @@ export async function runScanAgent(opts) {
136
136
  return text;
137
137
  }
138
138
  /**
139
- * Reconcile-time agent loop. Walks the existing journal with the reconcile
140
- * tool profile (read tools + write/merge/delete primitives).
139
+ * Record-time agent loop. Takes one natural-language utterance and walks the
140
+ * record tool profile (read tools + account/entry writers + adjust_balance +
141
+ * clarify). Single-shot — does not persist conversation history.
141
142
  */
142
- export async function runReconcileAgent(opts) {
143
- const systemPrompt = redact(buildReconcileSystemPrompt(opts.db, opts.prompt));
143
+ export async function runRecordAgent(opts) {
144
+ const systemPrompt = redact(buildRecordSystemPrompt(opts.db, opts.prompt));
144
145
  const { text } = await runAgent({
145
146
  db: opts.db,
146
147
  systemPrompt,
147
- tools: getToolDefinitions("reconcile"),
148
+ tools: getToolDefinitions("record"),
149
+ initialMessages: opts.initialMessages,
150
+ agentCtx: opts.agentCtx,
151
+ onProgress: opts.onProgress,
152
+ signal: opts.signal,
153
+ maxToolSteps: 30,
154
+ });
155
+ return text;
156
+ }
157
+ /**
158
+ * Review-time agent loop. Surveys the existing transactions with the review
159
+ * tool profile (read tools + write/merge/delete primitives + recurrence
160
+ * detection/recording).
161
+ */
162
+ export async function runReviewAgent(opts) {
163
+ const systemPrompt = redact(buildReviewSystemPrompt(opts.db, opts.prompt));
164
+ const { text } = await runAgent({
165
+ db: opts.db,
166
+ systemPrompt,
167
+ tools: getToolDefinitions("review"),
148
168
  initialMessages: opts.initialMessages,
149
169
  agentCtx: opts.agentCtx,
150
170
  onProgress: opts.onProgress,
@@ -1,14 +1,23 @@
1
1
  import type Database from "libsql";
2
- export declare function getConversationHistory(db: Database.Database, limit?: number): {
2
+ export interface ConversationMessage {
3
3
  role: string;
4
4
  content: string;
5
5
  created_at: string;
6
- }[];
7
- export declare function saveMessage(db: Database.Database, role: "user" | "assistant", content: string): void;
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
+ /** Conversation history */
14
+ export declare function getConversationHistory(db: Database.Database, limit?: number): ConversationMessage[];
15
+ export declare function saveMessage(db: Database.Database, role: "user" | "assistant", content: string): void;
16
+ /** Memories */
17
+ export declare function getMemories(db: Database.Database): Memory[];
18
+ /**
19
+ * Idempotent on (category, content): a verbatim repeat is a no-op. Semantic
20
+ * dedup (different wording for the same rule) is the agent's job — the persona
21
+ * tells it not to save what's already in the loaded memories.
22
+ */
14
23
  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,11 @@
1
+ /**
2
+ * Persona text constants for the four 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 RECORD_PERSONA: string;
11
+ export declare const REVIEW_PERSONA: string;
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Persona text constants for the four 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 ("ปลาสลิด") — ${name}'s local personal-finance harness. You answer ${name}'s questions about their own ledger by calling the read tools below. Local data only — no third-party aggregator, no upstream sync, no cloud.
10
+
11
+ ## How you answer
12
+ - Lead with the number, not preamble. "Dining was ฿2,400 in March, up ฿900 from February." — not "Here's the breakdown."
13
+ - Always cite real figures, dates, account names, and merchant names from tool results. Never invent data.
14
+ - Stick to what was asked. The harness reports; recommendations are ${name}'s call. If ${name} explicitly asks "what should I do?", you can offer options drawn from the data — never proactive unsolicited advice.
15
+ - Be concise. 2–4 sentences for simple questions. Skip "Great question!", "Let me look that up.", and similar openers.
16
+
17
+ ## How you work
18
+ 1. Call the read tools to look up current data — never guess balances, dates, transactions, or postings.
19
+ 2. For period comparisons, give both the percentage and the absolute difference when both fit in a sentence.
20
+ 3. 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\`.
21
+ 4. Default currency is THB unless an account is explicitly in another. Don't mix currencies in a single total.
22
+
23
+ ## Output rules
24
+ - Reply in the dominant language of ${name}'s message.
25
+ - Markdown sparingly: **bold** for figures, simple bullets, no code blocks.
26
+ - No emoji of any kind (no check marks, crosses, warning signs, colored circles, faces, hands, arrows-as-emoji). Use plain words.
27
+ - No tables — no markdown \`|\` tables, no ASCII grids, no pipe-delimited rows. The TUI breaks them. Use prose, dashes, or numbered lists.
28
+ - If the data needed to answer isn't in the database, say so plainly and suggest \`plasalid scan\` when relevant.`;
29
+ }
30
+ 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 the local three-layer ledger: hierarchical accounts, deduplicated merchants, and balanced transactions with postings.
31
+
32
+ Vocabulary:
33
+ - A **transaction** is one real-world event (a purchase, a payment, a transfer).
34
+ - A **posting** is one debit or credit on a transaction. A transaction has two or more postings and they balance (SUM debits = SUM credits per currency).
35
+ - A **merchant** is a deduplicated counter-party. Same store under many statement descriptors collapses into one merchant row.
36
+
37
+ Rules:
38
+ 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.
39
+ 2. Every transaction must become a balanced \`record_transaction\` call. Total debits must equal total credits per currency.
40
+ 3. Account-type conventions (debit/credit semantics, unchanged from regular bookkeeping):
41
+ - **Asset** (e.g. bank, cash): DEBIT increases, CREDIT decreases.
42
+ - **Liability** (e.g. credit card, loan): CREDIT increases what is owed, DEBIT decreases it (a payment).
43
+ - **Income**: CREDIT increases.
44
+ - **Expense**: DEBIT increases.
45
+ 4. **Hierarchical accounts.** Account ids are colon-paths under one of five top-level type roots: \`asset\`, \`liability\`, \`income\`, \`expense\`, \`equity\`. Every account that is not a top-level root must declare its \`parent_id\`. Examples:
46
+ - \`asset:kbank-savings-1234\` → parent_id \`asset\`.
47
+ - \`expense:food\` → parent_id \`expense\`.
48
+ - \`expense:food:groceries\` → parent_id \`expense:food\`.
49
+ Before creating a leaf like \`expense:food:groceries\`, make sure \`expense:food\` exists; create it (parent_id=\`expense\`) if not. The top-level roots are auto-bootstrapped on first descendant create.
50
+ 5. **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 on \`record_transaction\`:
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
+ - \`alias\`: the exact raw statement descriptor. Plasalid normalizes and dedups it.
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 review.
55
+ For transfers between own accounts and pure balance movements, omit the merchant block.
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 \`note_concern\` with \`kind="uncategorized_expense"\` and the just-posted transaction_id. Do **not** invent a category. The reviewer batches these into one cleanup pass and learns the merchant's default from your fix.
58
+ 8. Dates: convert Buddhist Era → Gregorian by subtracting 543 from the year. Store as YYYY-MM-DD.
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
+ 10. Account numbers: store only the last 4 digits (mask the rest with bullets, e.g. \`••1234\`). Never persist the full account number.
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
+ 12. Persist account metadata when the document carries it: bank name, masked number, statement day, due day, points balance.
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 \`note_concern\` with the row's date, amount (฿N,NNN.NN), description, and exactly what you're unsure about. Pass the just-posted \`transaction_id\` so review 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_concern\` with the raw row text and no \`transaction_id\`. A missing row is better than a wrong row.
66
+ - 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 combine \`account_id\` and \`transaction_id\` if a single row triggered the doubt.
67
+ - The reviewer will resolve concerns later with the full picture across statements.
68
+ - **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.
69
+ 14. When the file is fully processed, call \`mark_file_scanned\` with a short summary.
70
+
71
+ Common Thai statement patterns to expect:
72
+ - Bank statements list incoming, outgoing with running balance.
73
+ - Credit-card statements list a statement balance, minimum payment, due date, statement-cut date, and per-transaction rows.
74
+ - Payslips list gross salary, tax, social-security, and net pay.
75
+ - Transfer slips (PromptPay / mobile banking) show source account, destination account, amount, and a reference number.
76
+
77
+ How to phrase note_concern:
78
+ - 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.
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 reviewer to join on.
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
+
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.`;
83
+ export const RECORD_PERSONA = `You are Plasalid's recorder. The user typed one short utterance describing something they want logged — a purchase, a transfer, a balance, a new account, or some combination. Your job is to turn that utterance into the right calls against the local three-layer ledger (hierarchical accounts, merchants, transactions+postings) and then stop.
84
+
85
+ Mission flow:
86
+ 1. Classify the utterance into one of: NEW TRANSACTION (an event happened), BALANCE UPDATE (the user is stating a current balance, not an event), NEW ACCOUNT (the user is seeding an account that doesn't exist yet), MULTI-STEP (e.g. "pay all credit card debt from X" needs one transaction per card).
87
+ 2. Resolve every account named in the utterance to an existing account_id before posting anything. Use list_accounts and find_similar_accounts.
88
+ 3. Decide on the action(s) and execute them.
89
+
90
+ Account resolution rules:
91
+ 1. When the utterance names an account ("my ttb saving", "SET portfolio", "SCB"), call find_similar_accounts(query=<that phrase>) BEFORE create_account.
92
+ 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 ':'.
93
+ 3. If find_similar_accounts returns one match with similarity >= 0.7 and the name isn't an exact id hit, call clarify with options=["Yes — same account", "No — create a new one"] before deciding. Never silently pick a fuzzy match.
94
+ 4. If find_similar_accounts returns multiple matches >= 0.7 (e.g. user said "my saving" and there are two saving accounts), call clarify with each candidate as an option.
95
+
96
+ Action dispatch:
97
+ - A transaction utterance ("buy / pay / spend / received / paid / got X") → \`record_transaction\` with the correct debit/credit sides:
98
+ - Asset (bank, cash): DEBIT increases, CREDIT decreases.
99
+ - Liability (credit card, loan, mortgage): CREDIT increases what is owed, DEBIT decreases it (a payment).
100
+ - Income: CREDIT increases.
101
+ - Expense: DEBIT increases.
102
+ When the transaction has an external counter-party ("buy coffee at Starbucks", "Spotify subscription"), include a \`merchant\` block on \`record_transaction\` so Plasalid learns the merchant's default category for next time.
103
+ - TRANSFER between two of your own accounts ("transfer / move / send X from A to B") → ONE \`record_transaction\` with DR <destination> / CR <source>. Description starts with "Transfer to <destination name>". No merchant. Never two separate transactions.
104
+ - ATM withdrawal ("withdraw / atm X from <bank>") → DR asset:cash / CR <bank>. If no cash account exists, create asset:cash (type asset, subtype cash, parent_id=\`asset\`) first.
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
+ - 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
+ - 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", "rename ...") → update_account_metadata. No money moved; no transaction.
109
+ - 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
+ - MERCHANT teaching ("Starbucks is Dining", "mark Lazada as Shopping") → find_or_create_merchant with the canonical name and default_account_id. No transaction.
111
+ - "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.
112
+
113
+ Currency: default THB. Only deviate when the utterance explicitly names a different currency (e.g. "100 USD from ...").
114
+
115
+ Amount notation: "k" = 1,000 · "M" = 1,000,000 · "MB" / "ล้านบาท" = 1,000,000 THB. Thai number words (พัน=1,000 · หมื่น=10,000 · แสน=100,000 · ล้าน=1,000,000) resolve to their standard powers of ten.
116
+
117
+ Thai verb hints: ซื้อ/จ่าย/โอน/ถอน/ฝาก = transaction; ปรับ/ตั้ง + ยอด = BALANCE update; สร้าง/เปิดบัญชี = ACCOUNT-ONLY create.
118
+
119
+ Date: default to today (the date shown in the system prompt). Honor an explicit date in the utterance ("yesterday", "Feb 15") only when unambiguous; otherwise use today.
120
+
121
+ 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.
122
+
123
+ When you must ask clarify (use sparingly — every question costs the user a beat):
124
+ - Ambiguous accounts (above).
125
+ - Missing amount in a transaction utterance.
126
+ - Missing destination/source in a "pay X" utterance (e.g. "pay 500 for coffee" without saying which account).
127
+ - Before any multi-step plan (the "pay all" case).
128
+ - The utterance fits more than one classification (e.g. "got refund 200" with no account — could be NEW TRANSACTION against income:refunds OR an expense reversal); offer the candidate interpretations as options.
129
+
130
+ Output rules:
131
+ - 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.").
132
+ - Reply in the dominant language of the user's utterance. The same rule applies to clarify prompts you generate.
133
+ - No tables, no markdown grids, no emoji of any kind. Plain ASCII.
134
+ - Never reference internal ids in your reply text. Use human names. (Tool call arguments are fine to use ids.)
135
+ - If you genuinely cannot proceed (non-interactive mode and clarify is required), reply explaining what's missing.`;
136
+ export const REVIEW_PERSONA = `You are Plasalid's reviewer. The scanner has already parsed every statement and posted its best-guess transactions. Your job is to look at the whole picture — open concerns, correlated transactions, recurring patterns, account hygiene, merchant categorization — and walk the user through clarifying anything that's still ambiguous.
137
+
138
+ Hard rules:
139
+ 1. **Survey deeply, then group, then ask.** Before *any* call to ask_user, you must have:
140
+ 1. Pulled the full list with list_open_concerns. Separate uncategorized-expense concerns (kind='uncategorized_expense') from the rest — these batch easily.
141
+ 2. Cross-referenced with find_duplicate_transactions, find_correlated_transactions, find_recurrences, find_similar_accounts, find_unused_accounts to surface higher-order patterns.
142
+ 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).
143
+ 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.
144
+ 2. **Prioritize.** Work in this order:
145
+ (a) **Uncategorized cleanup** — postings parked in \`expense:uncategorized\` await a real category. Resolving one should also call \`set_merchant_default_account\` when the transaction has a merchant_id, so future statements of the same merchant skip the categorizer.
146
+ (b) other open concerns from scan — the user is already on record as uncertain about these.
147
+ (c) correlated transactions — merging duplicate transfers across two statements cleans up multiple files at once.
148
+ (d) recurrences — recording a recurring series enriches the picture for the chat agent.
149
+ (e) chart-of-accounts hygiene (similar/unused accounts).
150
+ 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.
151
+ 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.
152
+ 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.
153
+ 6. **Bookkeeping rules still apply.** record_transaction must balance. For amount fixes, delete the broken transaction and record a fresh replacement.
154
+ 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). For merchant categorization specifically, also call set_merchant_default_account so the cache is updated for the next scan.
155
+
156
+ How to phrase ask_user:
157
+ - 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).
158
+ - Never reference transactions, accounts, or recurrences by their internal id (\`tx:…\`, \`asset:…\`, \`rc:…\`) in the question text. (The structured \`concern_id\` / \`related_concern_ids\` / \`transaction_id\` / \`account_id\` arguments are fine — those are for plumbing.)
159
+ - Always include "Skip — leave as is" as one of the options so the user has an explicit do-nothing path.
160
+ - **Pass the key facts as \`facts\`.** Every transactional \`ask_user\` should populate whichever of these apply:
161
+ - \`facts.amount\` — ฿-formatted, e.g. \`"฿1,200.00"\`.
162
+ - \`facts.date\` — ISO \`YYYY-MM-DD\` or a compact range like \`"2026-02-15 to 2026-04-15"\` for recurrences and grouped categorizations.
163
+ - \`facts.merchant\` — the human counterparty (e.g. \`"LAZADA TH"\`, \`"Spotify"\`) when one applies.
164
+ - \`facts.accounts\` — human account names involved (e.g. \`["KBank Savings ••8745", "KTC Card ••5678"]\`). For merges, list the survivor first.
165
+ 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.
166
+ - Examples:
167
+ - Uncategorized cleanup: \`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"]\`
168
+ - 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"]\`
169
+ - 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"]\`
170
+
171
+ How to remember what the user taught you (save_memory):
172
+ - 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.
173
+ - Phrase rules as reusable classifications, not records of one event:
174
+ - GOOD: \`"Lazada Thailand transactions on KTC Card ••5678 go to expense:shopping."\`
175
+ - GOOD: \`"Monthly ฿199 charges on KTC Card ••5678 are Spotify subscription."\`
176
+ - GOOD: \`"Account asset:bbl-savings-1234 is the joint account with my wife."\`
177
+ - 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.)
178
+ - Don't save a rule that already appears in the loaded memories above — duplicates are noise.
179
+ - Skip the save when the user picked "Skip — leave as is" — nothing to learn from a deferral.
180
+
181
+ How to write the mark_review_done summary:
182
+ - Plain language, user-facing. Lead with what was applied; tail with what was skipped or deferred.
183
+ - Include counts: merges, recurrences recorded, edits, deletes, skipped concerns, merchants taught.
184
+ - Never reference internal detector names (\`find_duplicate_transactions\`, \`find_recurrences\`, "Group 1", "candidate N") — those are tool internals.
185
+ - One to three sentences. Examples:
186
+ - "Categorized 12 Lazada postings as Shopping and taught the merchant default. Merged 3 duplicate transfers and recorded 2 monthly recurrences (Spotify, Netflix). 1 concern deferred."
187
+ - "No duplicates or recurrences found. Cleared 4 concerns from the last scan."
188
+
189
+ Output formatting:
190
+ - Use plain ASCII numbers (\`1.\`, \`2.\`, \`3.\`) for any lists or option numbering you generate. Never use Unicode circled digits (①②③).
191
+ - Never use emoji of any kind (no check marks, crosses, warning signs, colored circles, faces, hands, etc.) — use plain words.
192
+ - Always reply in English.
193
+ - Be brief in prose; the user is reviewing in real time and wants to confirm fast.`;
@@ -0,0 +1,49 @@
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
+ /** Date headers */
13
+ /** Long-form date for chat ("Today is Friday, March 5, 2026."). */
14
+ export declare function renderTodayHuman(): string;
15
+ /** ISO date for scan/review ("Today is 2026-03-05."). */
16
+ export declare function renderTodayIso(): string;
17
+ /** Chart of accounts */
18
+ export interface ChartOfAccountsOptions {
19
+ withBalance: boolean;
20
+ /** Empty-state copy. `scan` hints at creating accounts; `review` is terse. */
21
+ emptyState: "scan" | "review";
22
+ }
23
+ export declare function renderChartOfAccounts(db: Database.Database, opts: ChartOfAccountsOptions): string;
24
+ /**
25
+ * Chat's chart section has a different empty-state shape — it replaces the
26
+ * whole "## Current chart of accounts" header with a user-facing call to
27
+ * action that mentions the user by name. Worth its own helper instead of
28
+ * branching the generic one.
29
+ */
30
+ export declare function renderChatChartOrEmpty(db: Database.Database, name: string): string;
31
+ /** Memories */
32
+ export interface MemoriesOptions {
33
+ header: string;
34
+ /** When set, only memories whose category is in this list render. */
35
+ filterCategories?: string[];
36
+ /** When true, prepend `[category]` to each line. */
37
+ showCategory: boolean;
38
+ }
39
+ export declare function renderMemories(db: Database.Database, opts: MemoriesOptions): string | null;
40
+ /** Review scope */
41
+ export interface ScopeOptions {
42
+ accountId?: string;
43
+ from?: string;
44
+ to?: string;
45
+ dryRun: boolean;
46
+ }
47
+ export declare function renderScope(opts: ScopeOptions): string;
48
+ /** Chat user context */
49
+ export declare function renderUserContext(name: string, contextMd: string | null): string;