plasalid 0.5.7 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +9 -9
  2. package/dist/accounts/taxonomy.d.ts +1 -1
  3. package/dist/accounts/taxonomy.js +2 -2
  4. package/dist/ai/agent.d.ts +8 -9
  5. package/dist/ai/agent.js +21 -20
  6. package/dist/ai/errors.d.ts +16 -0
  7. package/dist/ai/errors.js +47 -0
  8. package/dist/ai/personas.d.ts +1 -1
  9. package/dist/ai/personas.js +69 -66
  10. package/dist/ai/prompt-sections.d.ts +4 -5
  11. package/dist/ai/prompt-sections.js +11 -11
  12. package/dist/ai/providers/anthropic.js +10 -4
  13. package/dist/ai/providers/openai.js +70 -56
  14. package/dist/ai/redactor.js +77 -51
  15. package/dist/ai/system-prompt.d.ts +2 -3
  16. package/dist/ai/system-prompt.js +5 -5
  17. package/dist/ai/tools/common.js +13 -5
  18. package/dist/ai/tools/index.js +15 -15
  19. package/dist/ai/tools/ingest.d.ts +2 -2
  20. package/dist/ai/tools/ingest.js +210 -87
  21. package/dist/ai/tools/merchants.js +27 -12
  22. package/dist/ai/tools/read.js +36 -20
  23. package/dist/ai/tools/record.js +79 -19
  24. package/dist/ai/tools/resolve.d.ts +2 -0
  25. package/dist/ai/tools/resolve.js +195 -0
  26. package/dist/ai/tools/types.d.ts +5 -7
  27. package/dist/cli/commands/accounts.js +2 -2
  28. package/dist/cli/commands/record.js +4 -2
  29. package/dist/cli/commands/resolve.d.ts +2 -0
  30. package/dist/cli/commands/resolve.js +13 -0
  31. package/dist/cli/commands/scan.js +18 -22
  32. package/dist/cli/commands/status.js +4 -2
  33. package/dist/cli/index.js +9 -9
  34. package/dist/cli/ink/hooks/useFooterText.js +1 -1
  35. package/dist/cli/ink/hooks/useTextInput.js +60 -69
  36. package/dist/cli/ink/scan_dashboard.d.ts +2 -2
  37. package/dist/cli/ink/scan_dashboard.js +3 -3
  38. package/dist/cli/setup.js +6 -3
  39. package/dist/cli/ux.js +1 -1
  40. package/dist/db/queries/account-balance.d.ts +140 -0
  41. package/dist/db/queries/account-balance.js +355 -0
  42. package/dist/db/queries/account_balance.d.ts +0 -1
  43. package/dist/db/queries/account_balance.js +0 -10
  44. package/dist/db/queries/action-log.d.ts +29 -0
  45. package/dist/db/queries/action-log.js +27 -0
  46. package/dist/db/queries/action_log.d.ts +1 -1
  47. package/dist/db/queries/concerns.d.ts +10 -0
  48. package/dist/db/queries/concerns.js +21 -0
  49. package/dist/db/queries/transactions.d.ts +3 -22
  50. package/dist/db/queries/transactions.js +4 -5
  51. package/dist/db/queries/unknowns.d.ts +62 -0
  52. package/dist/db/queries/unknowns.js +114 -0
  53. package/dist/db/schema.js +3 -3
  54. package/dist/resolver/pipeline.d.ts +16 -0
  55. package/dist/resolver/pipeline.js +38 -0
  56. package/dist/resolver/prompts.d.ts +8 -0
  57. package/dist/resolver/prompts.js +26 -0
  58. package/dist/scanner/account-mutex.d.ts +1 -0
  59. package/dist/scanner/account-mutex.js +16 -0
  60. package/dist/scanner/buffer.d.ts +10 -10
  61. package/dist/scanner/buffer.js +15 -15
  62. package/dist/scanner/concurrency.d.ts +10 -7
  63. package/dist/scanner/concurrency.js +3 -16
  64. package/dist/scanner/decrypt-queue.d.ts +57 -0
  65. package/dist/scanner/decrypt-queue.js +114 -0
  66. package/dist/scanner/decrypt_queue.js +56 -38
  67. package/dist/scanner/detectors/correlations.d.ts +2 -0
  68. package/dist/scanner/detectors/correlations.js +51 -0
  69. package/dist/scanner/detectors/duplicates.d.ts +2 -0
  70. package/dist/scanner/detectors/duplicates.js +75 -0
  71. package/dist/scanner/detectors/index.d.ts +18 -0
  72. package/dist/scanner/detectors/index.js +39 -0
  73. package/dist/scanner/detectors/recurrences.d.ts +2 -0
  74. package/dist/scanner/detectors/recurrences.js +49 -0
  75. package/dist/scanner/detectors/similar_accounts.d.ts +2 -0
  76. package/dist/scanner/detectors/similar_accounts.js +64 -0
  77. package/dist/scanner/detectors/similarities.d.ts +2 -0
  78. package/dist/scanner/detectors/similarities.js +73 -0
  79. package/dist/scanner/detectors/types.d.ts +16 -0
  80. package/dist/scanner/detectors/types.js +1 -0
  81. package/dist/scanner/inspectors/correlations.d.ts +2 -0
  82. package/dist/scanner/inspectors/correlations.js +47 -0
  83. package/dist/scanner/inspectors/duplicates.d.ts +2 -0
  84. package/dist/scanner/inspectors/duplicates.js +75 -0
  85. package/dist/scanner/inspectors/index.d.ts +19 -0
  86. package/dist/scanner/inspectors/index.js +39 -0
  87. package/dist/scanner/inspectors/recurrences.d.ts +2 -0
  88. package/dist/scanner/inspectors/recurrences.js +49 -0
  89. package/dist/scanner/inspectors/similarities.d.ts +2 -0
  90. package/dist/scanner/inspectors/similarities.js +73 -0
  91. package/dist/scanner/inspectors/types.d.ts +16 -0
  92. package/dist/scanner/inspectors/types.js +1 -0
  93. package/dist/scanner/pdf-unlock.js +3 -1
  94. package/dist/scanner/pipeline.d.ts +6 -4
  95. package/dist/scanner/pipeline.js +63 -102
  96. package/dist/scanner/prompts.js +2 -2
  97. package/package.json +2 -1
package/README.md CHANGED
@@ -19,21 +19,21 @@ In markets like Thailand there's no Plaid: no public API that gives apps a unifi
19
19
 
20
20
  ## Features
21
21
 
22
- Plasalid is a chain of three stages: **Scan → 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.
22
+ Plasalid is a chain of three stages: **Scan → Resolve → Chat.** Underneath sits a three-layer ledger: hierarchical accounts (small, stable, colon-path ids like `expense:food:groceries`), deduplicated merchants (raw statement descriptors collapse to one canonical name with a learned default category), and balanced transactions with postings. Today's chat is one consumer; the same data will power a local MCP / API server next.
23
23
 
24
24
  ### Scan — parse without blocking
25
25
 
26
26
  - **Drop PDFs in, get balanced transactions out.** The scanner infers account type, masks account numbers, converts Buddhist-Era dates, and posts a double-entry record for every transaction.
27
27
  - **Merchants as first-class.** Statement descriptors (`STARBUCKS #1234 BKK`, `Starbucks #5678 BANGKOK`) normalize to one canonical merchant. Categorize a merchant once; future statements use the cached default category — the LLM skips re-categorizing known merchants.
28
- - **Never pauses to ask you.** Ambiguous rows post best-guess transactions with a structured *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.
28
+ - **Never pauses to ask you.** Ambiguous rows post best-guess transactions with a structured *unknown* attached; lines the scanner can't confidently categorize land in `expense:uncategorized` for the resolve cleanup pass; unparseable rows are skipped, not guessed. A missing row is better than a wrong row — resolve clears them up later.
29
29
  - **Encrypted PDFs handled inline.** Statement password-protected? Plasalid prompts you once, remembers the password (AES-GCM at rest) under a filename pattern, and unlocks next month's statement silently.
30
30
 
31
- ### Reviewsee the whole picture
31
+ ### Resolveclose every open unknown
32
32
 
33
33
  - **Uncategorized cleanup.** Every posting parked in `expense:uncategorized` shows up here; categorizing one teaches the merchant's default account for next time, so a single answer can resolve dozens of rows across future months.
34
34
  - **Connects related transactions.** A transfer that lands on both a bank statement and a credit-card statement is surfaced as one pair; merge on confirmation.
35
35
  - **Recurrences as first-class data.** Spotify, salary, rent get their own `recurrences` rows with cadence (weekly / biweekly / monthly / annually) and next-expected dates, linked back to every member transaction. Not a UI category — a structured fact any AI consumer can read.
36
- - **Step-by-step clarification.** Re-poses every scan-noted concern as one focused question; loops until concerns are clear or you skip them. `--dry-run` previews everything; writes only after you confirm.
36
+ - **Step-by-step clarification.** Re-poses every scan-noted unknown as one focused question; loops until unknowns are clear or you skip them.
37
37
 
38
38
  ### Chat — ask questions about your data
39
39
 
@@ -66,14 +66,14 @@ Then:
66
66
 
67
67
  1. Run `plasalid open` to pop open your data folder in Finder/Explorer, then drag in any bank or credit-card statement PDF you've got. **One file is enough to start** — Plasalid will already give you useful answers about that account. More files make the picture richer.
68
68
  2. Run `plasalid scan` — it parses your PDFs end-to-end without stopping.
69
- 3. Run `plasalid review` to connect related transactions, learn your recurring rhythms, and clear up anything the scanner flagged as a concern.
69
+ 3. Run `plasalid resolve` to connect related transactions, learn your recurring rhythms, and clear up anything the scanner flagged as a unknown.
70
70
  4. Run `plasalid` to chat with what was scanned.
71
71
 
72
72
  Other day-to-day commands:
73
73
 
74
74
  - `plasalid scan <regex>` — only scan files whose path matches the regex.
75
75
  - `plasalid scan <regex> --force` — re-scan matching files (replaces prior records).
76
- - `plasalid review --dry-run` — preview the picture (correlated transactions, recurrences, open concerns) without writing; re-run without `--dry-run` to step through fixes interactively.
76
+ - `plasalid resolve` — walk every open unknown one at a time and apply your decision (categorize, merge duplicates, link recurrences, skip). Filter with `--account`, `--from`, `--to`, or `--kind`.
77
77
  - `plasalid revert <regex>` — delete scanned files matching the regex and every transaction derived from them.
78
78
 
79
79
  ## Commands
@@ -90,7 +90,7 @@ plasalid transactions # List transactions and their postings (filt
90
90
  plasalid record <utterance> # Add a manual transaction, account, balance, or merchant from a plain-language line
91
91
  plasalid scan [regex] [--force] # Scan new PDFs; --force cascade-deletes prior records before re-scanning
92
92
  plasalid revert <regex> # Delete scanned files matching <regex> and their transactions
93
- plasalid review [--dry-run] # Connect related transactions, learn recurring rhythms, resolve open concerns (--account, --from, --to also accepted)
93
+ plasalid resolve # Walk every open unknown and apply your decision (--account, --from, --to, --kind also accepted)
94
94
  ```
95
95
 
96
96
  ## How It Works
@@ -109,7 +109,7 @@ plasalid review [--dry-run] # Connect related transactions, learn recurr
109
109
  Claude API (PII-redacted)
110
110
 
111
111
  ┌──────────▼──────────┐
112
- │ Encrypted DB │◀──── plasalid review
112
+ │ Encrypted DB │◀──── plasalid resolve
113
113
  └──────────┬──────────┘
114
114
 
115
115
  plasalid
@@ -138,7 +138,7 @@ Plasalid stores everything in `~/.plasalid/`:
138
138
  data/ # Drop any PDFs here (subfolders allowed; AI classifies)
139
139
  ```
140
140
 
141
- `db.sqlite` holds the three-layer ledger (hierarchical accounts, deduplicated merchants with learned default categories, transactions and postings), scan history, open 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.
141
+ `db.sqlite` holds the three-layer ledger (hierarchical accounts, deduplicated merchants with learned default categories, transactions and postings), scan history, open unknowns awaiting resolve, recurring transactions (Spotify, salary, rent — recognized during resolve and linked from each member transaction), an action log for record-mode audit, persisted long-term memories, and AES-GCM-encrypted PDF passwords keyed by filename pattern. Everything is wrapped in libsql's AES-256 page encryption.
142
142
 
143
143
  ### Environment Variables
144
144
 
@@ -24,7 +24,7 @@ export declare const SUGGESTED_LIABILITY_SUBTYPES: string[];
24
24
  export declare const SUGGESTED_EXPENSE_SUBTYPES: string[];
25
25
  export declare const SUGGESTED_INCOME_SUBTYPES: string[];
26
26
  /**
27
- * Stringified Thai taxonomy block for the scan/review system prompts.
27
+ * Stringified Thai taxonomy block for the scan/resolve system prompts.
28
28
  * Lists known Thai institutions and suggested subtypes so the model picks
29
29
  * consistent `bank_name` and `subtype` values across statements.
30
30
  */
@@ -122,7 +122,7 @@ export const ACCOUNT_TYPE_DESCRIPTIONS = {
122
122
  liability: "Credit cards, loans, mortgages, money the user owes.",
123
123
  income: "Salary, side income, dividends, refunds.",
124
124
  expense: "Spending categories (food, transport, utilities, etc.).",
125
- equity: "Owner's equity / opening balance equity (for review adjustments).",
125
+ equity: "Owner's equity / opening balance equity (for ledger adjustments).",
126
126
  };
127
127
  export const SUGGESTED_ASSET_SUBTYPES = [
128
128
  "bank",
@@ -169,7 +169,7 @@ export const SUGGESTED_INCOME_SUBTYPES = [
169
169
  "other",
170
170
  ];
171
171
  /**
172
- * Stringified Thai taxonomy block for the scan/review system prompts.
172
+ * Stringified Thai taxonomy block for the scan/resolve system prompts.
173
173
  * Lists known Thai institutions and suggested subtypes so the model picks
174
174
  * consistent `bank_name` and `subtype` values across statements.
175
175
  */
@@ -1,16 +1,14 @@
1
1
  import type Database from "libsql";
2
- import { type ScanPromptOptions, type ReviewPromptOptions, type RecordPromptOptions } from "./system-prompt.js";
2
+ import { type ScanPromptOptions, type ResolvePromptOptions, type RecordPromptOptions } from "./system-prompt.js";
3
3
  import { type AgentExecutionContext } from "./tools/index.js";
4
4
  import type { NormalizedMessage } from "./provider.js";
5
+ export { AbortedError } from "./errors.js";
5
6
  export type ProgressCallback = (event: {
6
7
  phase: "tool" | "responding";
7
8
  toolName?: string;
8
9
  toolCount: number;
9
10
  elapsedMs: number;
10
11
  }) => void;
11
- export declare class AbortedError extends Error {
12
- constructor();
13
- }
14
12
  /**
15
13
  * Conversational chat used by the Ink TUI. Reuses conversation_history for context
16
14
  * continuity, redacts PII on the way out, restores it on the way in for display.
@@ -43,14 +41,15 @@ export declare function runRecordAgent(opts: {
43
41
  signal?: AbortSignal;
44
42
  }): Promise<string>;
45
43
  /**
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).
44
+ * Resolve-time agent loop. The pipeline calls this once per open unknown with
45
+ * that unknown's id/prompt/options in the initial messages. The agent surfaces
46
+ * the question, applies the user's chosen answer via mutation tools, and
47
+ * finishes with mark_resolve_done.
49
48
  */
50
- export declare function runReviewAgent(opts: {
49
+ export declare function runResolveAgent(opts: {
51
50
  db: Database.Database;
52
51
  initialMessages: NormalizedMessage[];
53
- prompt: ReviewPromptOptions;
52
+ prompt: ResolvePromptOptions;
54
53
  agentCtx: AgentExecutionContext;
55
54
  onProgress?: ProgressCallback;
56
55
  signal?: AbortSignal;
package/dist/ai/agent.js CHANGED
@@ -1,17 +1,13 @@
1
1
  import { config } from "../config.js";
2
- import { buildChatSystemPrompt, buildScanSystemPrompt, buildReviewSystemPrompt, buildRecordSystemPrompt, } from "./system-prompt.js";
2
+ import { buildChatSystemPrompt, buildScanSystemPrompt, buildResolveSystemPrompt, buildRecordSystemPrompt, } from "./system-prompt.js";
3
3
  import { getToolDefinitions, executeTool } from "./tools/index.js";
4
4
  import { getConversationHistory, saveMessage } from "./memory.js";
5
5
  import { redact, unredact } from "./redactor.js";
6
6
  import { createProvider } from "./providers/index.js";
7
+ import { AbortedError, ApiAuthError, ApiError, RateLimitError, } from "./errors.js";
8
+ export { AbortedError } from "./errors.js";
7
9
  const provider = createProvider();
8
10
  const MAX_TOOL_STEPS = 20;
9
- export class AbortedError extends Error {
10
- constructor() {
11
- super("aborted");
12
- this.name = "AbortedError";
13
- }
14
- }
15
11
  async function runAgent({ db, systemPrompt, tools, initialMessages, agentCtx, onProgress, signal, maxToolSteps, }) {
16
12
  const messages = [...initialMessages];
17
13
  const useThinking = config.thinkingBudget > 0 && provider.supportsThinking;
@@ -102,17 +98,21 @@ export async function handleChatMessage(db, userMessage, onProgress, signal) {
102
98
  return text || "I couldn't formulate a response. Could you rephrase?";
103
99
  }
104
100
  catch (error) {
105
- if (error instanceof AbortedError || error?.name === "AbortError" || signal?.aborted) {
101
+ if (error instanceof AbortedError)
102
+ throw error;
103
+ if (signal?.aborted)
106
104
  throw new AbortedError();
107
- }
108
- if (error.status === 401 || error.status === 403) {
105
+ if (error instanceof ApiAuthError) {
109
106
  return "API key was rejected. Run `plasalid setup` to reconfigure your credentials.";
110
107
  }
111
- if (error.status === 429) {
108
+ if (error instanceof RateLimitError) {
112
109
  return "Rate limited. Wait a moment and try again.";
113
110
  }
114
- const safeMessage = error.status ? `API error (${error.status}): ${error.message || ""}` : error.message || "internal error";
115
- console.error("AI error:", safeMessage);
111
+ if (error instanceof ApiError) {
112
+ console.error("AI error:", `API error (${error.status ?? "?"}): ${error.message}`);
113
+ return "Sorry, I had trouble processing that. Could you try again?";
114
+ }
115
+ console.error("AI error:", error.message || "internal error");
116
116
  return "Sorry, I had trouble processing that. Could you try again?";
117
117
  }
118
118
  }
@@ -155,21 +155,22 @@ export async function runRecordAgent(opts) {
155
155
  return text;
156
156
  }
157
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).
158
+ * Resolve-time agent loop. The pipeline calls this once per open unknown with
159
+ * that unknown's id/prompt/options in the initial messages. The agent surfaces
160
+ * the question, applies the user's chosen answer via mutation tools, and
161
+ * finishes with mark_resolve_done.
161
162
  */
162
- export async function runReviewAgent(opts) {
163
- const systemPrompt = redact(buildReviewSystemPrompt(opts.db, opts.prompt));
163
+ export async function runResolveAgent(opts) {
164
+ const systemPrompt = redact(buildResolveSystemPrompt(opts.db, opts.prompt));
164
165
  const { text } = await runAgent({
165
166
  db: opts.db,
166
167
  systemPrompt,
167
- tools: getToolDefinitions("review"),
168
+ tools: getToolDefinitions("resolve"),
168
169
  initialMessages: opts.initialMessages,
169
170
  agentCtx: opts.agentCtx,
170
171
  onProgress: opts.onProgress,
171
172
  signal: opts.signal,
172
- maxToolSteps: 60,
173
+ maxToolSteps: 30,
173
174
  });
174
175
  return text;
175
176
  }
@@ -0,0 +1,16 @@
1
+ export declare class AbortedError extends Error {
2
+ constructor();
3
+ }
4
+ export declare class ApiAuthError extends Error {
5
+ readonly status: number;
6
+ constructor(status: number);
7
+ }
8
+ export declare class RateLimitError extends Error {
9
+ readonly status = 429;
10
+ constructor();
11
+ }
12
+ export declare class ApiError extends Error {
13
+ readonly status: number | undefined;
14
+ constructor(status: number | undefined, message: string);
15
+ }
16
+ export declare function classifyProviderError(err: unknown, signal?: AbortSignal): never;
@@ -0,0 +1,47 @@
1
+ export class AbortedError extends Error {
2
+ constructor() {
3
+ super("aborted");
4
+ this.name = "AbortedError";
5
+ }
6
+ }
7
+ export class ApiAuthError extends Error {
8
+ status;
9
+ constructor(status) {
10
+ super(`auth ${status}`);
11
+ this.status = status;
12
+ this.name = "ApiAuthError";
13
+ }
14
+ }
15
+ export class RateLimitError extends Error {
16
+ status = 429;
17
+ constructor() {
18
+ super("rate limited");
19
+ this.name = "RateLimitError";
20
+ }
21
+ }
22
+ export class ApiError extends Error {
23
+ status;
24
+ constructor(status, message) {
25
+ super(message);
26
+ this.status = status;
27
+ this.name = "ApiError";
28
+ }
29
+ }
30
+ export function classifyProviderError(err, signal) {
31
+ if (err instanceof AbortedError ||
32
+ err instanceof ApiAuthError ||
33
+ err instanceof RateLimitError ||
34
+ err instanceof ApiError) {
35
+ throw err;
36
+ }
37
+ const e = (err ?? {});
38
+ if (signal?.aborted || e.name === "AbortError")
39
+ throw new AbortedError();
40
+ if (e.status === 401 || e.status === 403)
41
+ throw new ApiAuthError(e.status);
42
+ if (e.status === 429)
43
+ throw new RateLimitError();
44
+ if (typeof e.status === "number")
45
+ throw new ApiError(e.status, e.message ?? "");
46
+ throw new ApiError(undefined, e.message ?? "internal error");
47
+ }
@@ -8,4 +8,4 @@
8
8
  export declare function chatPersona(name: string): string;
9
9
  export declare const SCAN_PERSONA: string;
10
10
  export declare const RECORD_PERSONA: string;
11
- export declare const REVIEW_PERSONA: string;
11
+ export declare const RESOLVE_PERSONA: string;
@@ -51,21 +51,21 @@ Rules:
51
51
  - \`canonical_name\`: Title-cased name (e.g. \`"Starbucks"\`, \`"Amazon"\`, \`"Spotify"\`). Normalize across descriptor variations — \`"STARBUCKS #1234 BKK"\`, \`"Starbucks #5678 BANGKOK"\`, \`"SBUX TH"\` all share \`"Starbucks"\`.
52
52
  - \`alias\`: the exact raw statement descriptor. Plasalid normalizes and dedups it.
53
53
  - \`default_account_id\`: when categorization is confident on first sight, set this to the matching expense account (e.g. \`expense:food:dining\` for Starbucks). The next scan that sees the same merchant will skip re-asking the LLM.
54
- Also set \`raw_descriptor\` on the transaction to the exact statement line for downstream review.
54
+ Also set \`raw_descriptor\` on the transaction to the exact statement line for downstream lookups.
55
55
  For transfers between own accounts and pure balance movements, omit the merchant block.
56
56
  6. **Pre-resolved merchants.** If the prompt context shows a merchant already known for the descriptor, use the supplied \`merchant_id\` and \`default_account_id\` on \`record_transaction\` instead of proposing a fresh merchant block. You may override the default expense account when the row's context says otherwise (e.g. a Starbucks gift-card top-up is not Dining).
57
- 7. **Suspense fallback.** If you cannot categorize an expense with reasonable confidence, post the expense side to \`expense:uncategorized\` (auto-created on first use) and call \`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.
57
+ 7. **Suspense fallback.** If you cannot categorize an expense with reasonable confidence, post the expense side to \`expense:uncategorized\` (auto-created on first use) and call \`note_unknown\` with \`kind="uncategorized_expense"\` and the just-posted transaction_id. Do **not** invent a category. The resolver batches these into one cleanup pass and learns the merchant's default from your fix.
58
58
  8. Dates: convert Buddhist Era → Gregorian by subtracting 543 from the year. Store as YYYY-MM-DD.
59
59
  9. Default currency is THB. Tag every posting with its ISO 4217 currency code on the \`record_transaction\` call; only deviate from THB when the row explicitly shows another currency (foreign-card purchases, FX transfers, multi-currency wallets).
60
60
  10. Account numbers: store only the last 4 digits (mask the rest with bullets, e.g. \`••1234\`). Never persist the full account number.
61
61
  11. If the document reveals an account that doesn't exist yet, call \`create_account\` once before posting transactions to it. Reuse existing accounts; don't create duplicates — call \`list_accounts\` first.
62
62
  12. Persist account metadata when the document carries it: bank name, masked number, statement day, due day, points balance.
63
63
  13. **Never pause for the user.** Your only job is to parse this document as accurately as possible.
64
- - If a row is ambiguous (unclear category, unclear sign, suspicious total), still post your best-guess \`record_transaction\`, then call \`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.
64
+ - If a row is ambiguous (unclear category, unclear sign, suspicious total), still post your best-guess \`record_transaction\`, then call \`note_unknown\` with the row's date, amount (฿N,NNN.NN), description, and exactly what you're unsure about. Pass the just-posted \`transaction_id\` so the resolver can find it.
65
+ - If a row is *unparseable* (amount unreadable, date missing entirely, can't tell what account is involved), **skip the row entirely** — do not call \`record_transaction\` with placeholder values. Call \`note_unknown\` with the raw row text and no \`transaction_id\`. A missing row is better than a wrong row.
66
+ - If you have a unknown about an **account itself** — the statement's bank name disagrees with the stored account, the currency disagrees, the statement_day/due_day on the statement conflicts with what's stored, or you suspect the account you're about to \`create_account\` duplicates an existing one but can't be sure — call \`note_unknown\` with \`account_id\` set. You can combine \`account_id\` and \`transaction_id\` if a single row triggered the doubt.
67
+ - The resolver will work through unknowns later with the full picture across statements.
68
+ - **Apply what you've already been told.** Before flagging a unknown, scan the "Rules you've already learned" section below. If a saved rule classifies the row — a merchant→category mapping, an account identity, a recurring-charge identity — apply it silently and do **not** raise a unknown. Only flag a unknown when the row genuinely doesn't fit any saved rule. Asking the user about something they've already told us is bad UX.
69
69
  14. When the file is fully processed, call \`mark_file_scanned\` with a short summary.
70
70
 
71
71
  Common Thai statement patterns to expect:
@@ -74,9 +74,9 @@ Common Thai statement patterns to expect:
74
74
  - Payslips list gross salary, tax, social-security, and net pay.
75
75
  - Transfer slips (PromptPay / mobile banking) show source account, destination account, amount, and a reference number.
76
76
 
77
- How to phrase 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.
77
+ How to phrase note_unknown:
78
+ - Write a complete sentence with enough context for a later resolver who doesn't have the PDF open: include the date, the amount (formatted as ฿N,NNN.NN), and the row's description.
79
+ - Never reference accounts or transactions by internal id (\`asset:…\`, \`tx:…\`) in the prompt text. Use the human account name (e.g. "KBank Savings ••8745"). The structured \`transaction_id\` and \`account_id\` arguments are fine — those are for the resolver to join on.
80
80
  - Provide \`options\` when the resolution is a small finite choice (e.g. which category to use, debit vs credit). When you do, always include "Skip — leave as is" as one of them.
81
81
 
82
82
  Output formatting: use plain ASCII numbers (\`1.\`, \`2.\`, \`3.\`) for any lists. Never use Unicode circled digits (①②③). Never use emoji of any kind (no check marks, crosses, warning signs, colored circles, faces, hands, etc.) — use plain words.`;
@@ -105,7 +105,9 @@ Action dispatch:
105
105
  - REFUND ("got refund X to <account>" or "refund X from <merchant>"): DR <account>; for the credit side, prefer reversing the related expense category if one is obvious from the utterance or saved memory, otherwise CR income:refunds (auto-create on demand with parent_id=\`income\`). Attach the merchant block when the merchant is named. Never use adjust_account_balance for a refund — money moved.
106
106
  - MULTI-ITEM single receipt ("lunch 200, drinks 100 from cash") → ONE \`record_transaction\` with one debit posting per item (each posting carries its own memo) and one credit posting totalling the sum. Don't split into separate transactions unless items are on different days or use different funding accounts.
107
107
  - BALANCE update ("set / update / now has / is now / networth of / portfolio is X") → adjust_account_balance with target_balance = the stated amount. The tool reads the current balance and posts the delta against equity:adjustments.
108
- - METADATA update ("set my KTC due day to 20", "statement day 28", "rename ...") → update_account_metadata. No money moved; no transaction.
108
+ - METADATA update ("set my KTC due day to 20", "statement day 28", "change masked number") → update_account_metadata. No money moved; no transaction.
109
+ - RENAME ("rename SCB to Bangkok Bank") → resolve the account via find_similar_accounts, then rename_account.
110
+ - DELETE ("delete my old empty cash account", "remove asset:old-savings") → resolve the account via find_similar_accounts, then delete_account. delete_account refuses if the account still has postings — tell the user to merge or recategorize first.
109
111
  - ACCOUNT-ONLY create ("create a new investment at Diem", "open a savings at SCB") → resolve any duplicate via find_similar_accounts first, then create_account. No transaction, no balance.
110
112
  - MERCHANT teaching ("Starbucks is Dining", "mark Lazada as Shopping") → find_or_create_merchant with the canonical name and default_account_id. No transaction.
111
113
  - "Pay all <category>" (e.g. "pay all credit card debt from X"): list_accounts filtered by type, get_account_balance for each, build the plan, call clarify with a one-line summary ("Settle 3 cards totaling ฿38,500 from SCB Savings — proceed?") before any record_transaction. Then post one transaction per liability.
@@ -133,61 +135,62 @@ Output rules:
133
135
  - No tables, no markdown grids, no emoji of any kind. Plain ASCII.
134
136
  - Never reference internal ids in your reply text. Use human names. (Tool call arguments are fine to use ids.)
135
137
  - If you genuinely cannot proceed (non-interactive mode and clarify is required), reply explaining what's missing.`;
136
- export const 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."
138
+ export const RESOLVE_PERSONA = `You are Plasalid's resolver. The user message hands you EVERY open unknown at once. Your goal is to close every one of them with as few user prompts as possible automate the obvious cases first; ask only when judgment is genuinely required.
139
+
140
+ Inputs you receive:
141
+ - One line per open unknown in the user message: id, kind, transaction/account/file ids, prompt, options.
142
+ - The "Rules you've already learned" section in the system promptauthoritative; apply silently.
143
+ - The current chart of accounts + balances in the system prompt.
144
+
145
+ The workflow is five steps. Do them in order. Do not skip step 1.
146
+
147
+ **Step 1 — Survey (no tool calls).** Read the entire unknown list. Build a mental map: which kinds appear, which unknowns share a merchant / descriptor / account pair, which rows a loaded memory rule covers, which kinds you can resolve via heuristic alone. The goal is to know the whole shape before mutating anything.
148
+
149
+ **Step 2 — Apply memory-driven silent resolutions.** For every unknown a loaded memory rule covers (merchant→category, known recurrence identity, "these two accounts are separate", account-purpose fact), apply the implied mutation, then call \`close_unknown\` with the implied answer. Group sibling unknowns under one \`close_unknown\` call via \`related_unknown_ids\` — one call per memory rule, not one per row.
150
+
151
+ **Step 3 — Apply per-kind heuristic defaults.** For unknowns not covered by memory, apply automatically when the heuristic is high-confidence:
152
+ - kind=\`duplicate\` if the two transactions share the same merchant on the same date in the same file, default "Keep both" silently. (The inspector already drops these at source, but if one leaks through, suppress it here.)
153
+ - kind=\`correlation\` if both sides are already linked to a recurrence, default "Keep separate" silently (recurring transfers aren't duplicates).
154
+ - kind=\`recurrence_candidate\` if a memory rule names the recurrence (e.g. "Monthly ฿199 on KTC Card Spotify subscription"), call \`record_recurrence\` with the candidate's transaction_ids and the implied frequency, then \`close_unknown\`.
155
+ - kind=\`uncategorized\` / \`uncategorized_expense\` if the transaction's merchant already has a default_account_id set, apply that category via \`update_posting\` and \`close_unknown\`. No need to re-ask.
156
+ - kind=\`similar_accounts\` if the two names differ only in casing/whitespace, that's a high-confidence merge; still group with a single \`ask_user\` (don't auto-merge without confirmation, but ask only once).
157
+
158
+ In each case, call \`close_unknown\` with the implied answer and \`related_unknown_ids\` if any siblings share that answer.
159
+
160
+ **Step 4 Group remaining unknowns, then ask ONCE per group.** Whatever survives steps 2-3 needs the user. Group by shared answer:
161
+ - All \`uncategorized\` / \`uncategorized_expense\` unknowns on the same merchant or \`raw_descriptor\` one group.
162
+ - All \`duplicate\` unknowns sharing the same pair of source files one group.
163
+ - All \`correlation\` unknowns between the same pair of accounts → one group.
164
+ - All \`recurrence_candidate\` unknowns on the same account + amount one group.
165
+ - All \`similar_accounts\` unknowns on the same account pair one group (usually one row already).
166
+
167
+ For each group, call \`ask_user\` ONCE, passing every sibling's id in \`related_unknown_ids\`. Include "Skip leave as is" as the last option. After the user answers, apply the mutation(s) the answer implies for every member of the group.
168
+
169
+ **Step 5 — Learn and finalize.** After every non-skip user answer that implies a generalizable rule (e.g. "Lazada on KTC Card Shopping"), call \`save_memory(content=<rule>, category="scanning_hint")\` so the next scan applies it silently. For merchant categorization, also call \`set_merchant_default_account\`. Phrase rules as reusable classifications, not one-event records (GOOD: "Lazada Thailand on KTC Card ••5678 expense:shopping." BAD: "On 2026-03-15 the user said Shopping.").
170
+
171
+ **Closing invariant.** Every unknown in the input list must have \`resolved_at\` set by the end. If anything is still open after step 4, close it with \`close_unknown(answer="Skip — could not interpret")\`.
172
+
173
+ End with one \`mark_resolve_done\` call. Summary format: a single sentence with counts. Examples:
174
+ - "Applied 9 from memory; resolved 2 groups (5 unknowns) by user answer; deferred 1 via Skip."
175
+ - "All 14 unknowns applied silently from memory rules."
176
+ - "Resolved 1 group (3 Lazada postings) as Shopping; saved the merchant default."
177
+
178
+ Unknown kind mutation tool map (use after a user answer in step 4):
179
+ - \`uncategorized\` / \`uncategorized_expense\` \`update_posting(account_id=...)\` for each posting on the transaction. If the transaction has a merchant_id, also \`set_merchant_default_account\`.
180
+ - \`duplicate\` → "Delete this one" → \`delete_transaction\` on the unknown's transaction_id. "Delete the older one" identify the older tx from the prompt body, then \`delete_transaction\`. "Keep both" / "Skip" → no mutation.
181
+ - \`correlation\` "Merge into one transaction" → \`delete_transaction\` on one side and \`update_posting\` on the other so it reflects the cross-account movement. "Keep separate" / "Skip" no mutation.
182
+ - \`recurrence_candidate\` → "Link as recurring" → \`record_recurrence\` with the candidate's transaction_ids and the implied frequency. "Not recurring" / "Skip" → no mutation.
183
+ - \`similar_accounts\` "Merge A into B" / "Merge B into A" → \`merge_accounts(from_id, to_id)\`. "Keep separate" / "Skip" → no mutation.
184
+
185
+ How to phrase \`ask_user\`:
186
+ - Use the unknown's \`prompt\` verbatim (or a tightened version when grouping). Don't restate amounts/dates/accounts in prose that's what \`facts\` is for.
187
+ - Pass the unknown's existing \`options\` verbatim. Don't invent options.
188
+ - Always pass the primary unknown's id as \`unknown_id\` and the siblings as \`related_unknown_ids\`.
189
+ - Populate \`facts\` whenever the unknown mentions an amount, date, merchant, or accounts (amount=yellow, date=cyan, merchant=green, accounts=magenta).
190
+ - Never reference internal ids (\`tx:…\`, \`asset:…\`, \`rc:…\`, \`cn:…\`) in the prompt text.
188
191
 
189
192
  Output formatting:
190
- - Use plain ASCII numbers (\`1.\`, \`2.\`, \`3.\`) for any lists 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.
193
+ - Use plain ASCII numbers (\`1.\`, \`2.\`, \`3.\`) for any lists. Never use Unicode circled digits.
194
+ - Never use emoji of any kind — use plain words.
192
195
  - Always reply in English.
193
- - Be brief in prose; the user is reviewing in real time and wants to confirm fast.`;
196
+ - Be terse; the user wants the final summary, not narration.`;
@@ -12,13 +12,13 @@ import type Database from "libsql";
12
12
  /** Date headers */
13
13
  /** Long-form date for chat ("Today is Friday, March 5, 2026."). */
14
14
  export declare function renderTodayHuman(): string;
15
- /** ISO date for scan/review ("Today is 2026-03-05."). */
15
+ /** ISO date for scan/resolve ("Today is 2026-03-05."). */
16
16
  export declare function renderTodayIso(): string;
17
17
  /** Chart of accounts */
18
18
  export interface ChartOfAccountsOptions {
19
19
  withBalance: boolean;
20
- /** Empty-state copy. `scan` hints at creating accounts; `review` is terse. */
21
- emptyState: "scan" | "review";
20
+ /** Empty-state copy. `scan` hints at creating accounts; `resolve` is terse. */
21
+ emptyState: "scan" | "resolve";
22
22
  }
23
23
  export declare function renderChartOfAccounts(db: Database.Database, opts: ChartOfAccountsOptions): string;
24
24
  /**
@@ -37,12 +37,11 @@ export interface MemoriesOptions {
37
37
  showCategory: boolean;
38
38
  }
39
39
  export declare function renderMemories(db: Database.Database, opts: MemoriesOptions): string | null;
40
- /** Review scope */
40
+ /** Resolve scope */
41
41
  export interface ScopeOptions {
42
42
  accountId?: string;
43
43
  from?: string;
44
44
  to?: string;
45
- dryRun: boolean;
46
45
  }
47
46
  export declare function renderScope(opts: ScopeOptions): string;
48
47
  /** Chat user context */