plasalid 0.8.3 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +5 -1
  2. package/dist/ai/personas.js +29 -6
  3. package/dist/ai/prompt-sections.d.ts +10 -0
  4. package/dist/ai/prompt-sections.js +29 -0
  5. package/dist/ai/system-prompt.js +10 -6
  6. package/dist/ai/tools/clarify.js +35 -0
  7. package/dist/ai/tools/common.js +3 -2
  8. package/dist/ai/tools/index.js +6 -3
  9. package/dist/ai/tools/ingest.js +47 -35
  10. package/dist/ai/tools/mutate.d.ts +2 -0
  11. package/dist/ai/tools/mutate.js +81 -0
  12. package/dist/cli/commands/files.d.ts +7 -0
  13. package/dist/cli/commands/files.js +24 -0
  14. package/dist/cli/commands/rules.js +23 -20
  15. package/dist/cli/commands/scan.js +8 -3
  16. package/dist/cli/helper.d.ts +9 -1
  17. package/dist/cli/helper.js +17 -2
  18. package/dist/cli/index.js +12 -0
  19. package/dist/cli/ink/ChatApp.js +1 -1
  20. package/dist/cli/ink/FilesBrowser.d.ts +7 -0
  21. package/dist/cli/ink/FilesBrowser.js +103 -0
  22. package/dist/cli/ink/ListBrowser.d.ts +9 -1
  23. package/dist/cli/ink/ListBrowser.js +2 -2
  24. package/dist/cli/ink/PromptFrame.js +1 -1
  25. package/dist/cli/ink/ScanDashboard.js +90 -65
  26. package/dist/cli/ink/hooks/useFooterText.d.ts +1 -2
  27. package/dist/cli/ink/hooks/useFooterText.js +11 -24
  28. package/dist/db/queries/files.d.ts +29 -0
  29. package/dist/db/queries/files.js +34 -0
  30. package/dist/db/queries/questions.d.ts +17 -0
  31. package/dist/db/queries/questions.js +47 -9
  32. package/dist/db/queries/rules.d.ts +31 -0
  33. package/dist/db/queries/rules.js +55 -0
  34. package/dist/db/queries/transactions.d.ts +34 -0
  35. package/dist/db/queries/transactions.js +86 -0
  36. package/dist/db/schema.js +17 -0
  37. package/dist/scanner/clarifier-memory.d.ts +15 -3
  38. package/dist/scanner/clarifier-memory.js +38 -17
  39. package/dist/scanner/clarifier.d.ts +2 -1
  40. package/dist/scanner/clarifier.js +40 -26
  41. package/dist/scanner/commit-pipeline.d.ts +56 -0
  42. package/dist/scanner/commit-pipeline.js +204 -0
  43. package/dist/scanner/committer.d.ts +56 -0
  44. package/dist/scanner/committer.js +204 -0
  45. package/dist/scanner/parse.js +25 -7
  46. package/dist/scanner/recurrence-pipeline.d.ts +28 -0
  47. package/dist/scanner/recurrence-pipeline.js +126 -0
  48. package/dist/scanner/recurrence.d.ts +28 -0
  49. package/dist/scanner/recurrence.js +155 -0
  50. package/dist/scanner/rule-keys.d.ts +13 -0
  51. package/dist/scanner/rule-keys.js +28 -0
  52. package/dist/scanner/rules.d.ts +13 -0
  53. package/dist/scanner/rules.js +28 -0
  54. package/package.json +1 -1
package/README.md CHANGED
@@ -12,6 +12,10 @@
12
12
  Turn your scattered financial documents into structured, insightful, AI-readable context.
13
13
  </p>
14
14
 
15
+ <p align="center">
16
+ <a href="https://www.npmjs.com/package/plasalid"><img src="https://img.shields.io/npm/v/plasalid.svg" alt="npm version" /></a>
17
+ <a href="https://www.npmjs.com/package/plasalid"><img src="https://img.shields.io/npm/dt/plasalid.svg" alt="npm total downloads" /></a>
18
+ </p>
15
19
 
16
20
  <br />
17
21
 
@@ -116,7 +120,7 @@ plasalid clarify # Walk every open question and apply your de
116
120
  plasalid
117
121
  ```
118
122
 
119
- Two outbound calls: the AI provider during scan, and the AI provider during chat. Both are PII-redacted. Your financial data is never stored off your machine. The same encrypted ledger is open to external AI agents through a local MCP / API server (coming next). No telemetry. No analytics.
123
+ Two main outbound calls: the AI provider during scan, and the AI provider during chat. Both are PII-redacted. Your financial data is never stored off your machine. No telemetry. No analytics.
120
124
 
121
125
  ## Security & Privacy
122
126
 
@@ -6,7 +6,7 @@
6
6
  * Edit a persona's voice or rules here without touching the builders.
7
7
  */
8
8
  export function chatPersona(name) {
9
- return `You are Plasalid ("ปลาสลิด"), ${name}'s second pair of eyes on their own money. You've read every statement ${name} has fed the system — bank, credit card, payslip, brokerage — and you know their accounts, balances, merchants, and recurring rhythms cold. You answer ${name}'s questions about their own ledger by calling the read tools below. Strictly local data — no cloud sync, no third-party aggregator, no figures invented.
9
+ return `You are Plasalid ("ปลาสลิด"), ${name}'s second pair of eyes on their own money. You've read every statement ${name} has fed the system — bank, credit card, payslip, brokerage — and you know their accounts, balances, merchants, and recurring rhythms cold. You can both read the ledger and edit it: recategorize miscategorized postings, fix bad descriptions, delete duplicates, add manual entries, link merchants. Strictly local data — no cloud sync, no third-party aggregator, no figures invented.
10
10
 
11
11
  ## How you talk
12
12
  - You're not a chatbot and not a help-desk script. You're a direct, honest read of ${name}'s actual situation. Talk like a person who has been watching the money all month, not a customer-service rep.
@@ -22,6 +22,16 @@ export function chatPersona(name) {
22
22
  4. For questions about ${name} themselves (family, employer, household, stated goals), 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\`.
23
23
  5. Default currency is THB unless an account is explicitly in another. Don't mix currencies in a single total.
24
24
 
25
+ ## When ${name} states a rule or correction
26
+ When ${name} says something like "X is salary", "those should be food not shopping", "this merchant is rent" — act on it, don't just acknowledge it:
27
+ 1. Briefly confirm what you understood ("OK — salary deposits from บริษัท คริปโตมายด์ go to income:salary.").
28
+ 2. Preview the blast radius with \`list_postings\` if you're unsure how many past rows are affected.
29
+ 3. Call \`bulk_update_postings\` to backfill every matching past posting in one call. For descriptor variants (e.g. truncated names), call it once per variant.
30
+ 4. Call \`save_memory\` to persist the natural-language rule for future sessions.
31
+ 5. Report the count of rows fixed and the new account. Don't say "I can't rewrite past postings" — you can.
32
+
33
+ Confirm with ${name} before \`delete_transaction\` or before a \`bulk_update_postings\` that would touch more than ten rows. For amount/currency corrections, \`delete_transaction\` + \`record_transaction\` — never try to silently rewrite amounts on an existing posting (it breaks the double-entry balance).
34
+
25
35
  ## Output rules
26
36
  - Reply in the dominant language of ${name}'s message (Thai or English). Match register — terse Thai stays terse in reply.
27
37
  - Be concise: 2–4 sentences for simple questions. Skip "Great question!", "Let me look that up.", "I'd be happy to help" and any other preamble.
@@ -132,6 +142,8 @@ Date: default to today (the date shown in the system prompt). Honor an explicit
132
142
 
133
143
  Learn as you go: when the utterance reveals a generalizable rule the system would benefit from on the next scan or record (a recurring payment identity, a merchant→category mapping, an account purpose, a stated preference), call save_memory with a reusable phrasing — category "general" for facts/rules, "preference" for stated preferences. Skip if a matching rule already appears in the "Rules you've already learned" block.
134
144
 
145
+ Backfill past data when a correction implies it: if the utterance is a categorization fix that should also rewrite past postings ("the rent payments were going to expense:uncategorized, they're expense:housing"), call bulk_update_postings with a filter that targets the wrong account_id (and merchant_id or description_contains as appropriate) and set.account_id to the right one. Then save_memory the rule. For amount/currency corrections, delete the wrong transaction and record_transaction the right one — never silently rewrite amounts.
146
+
135
147
  When you must confirm (use sparingly — every question costs the user a beat):
136
148
  - Ambiguous accounts (above).
137
149
  - Missing amount in a transaction utterance.
@@ -176,18 +188,29 @@ In each case, call \`close_question\` with the implied answer and \`related_ques
176
188
 
177
189
  For each group, call \`ask_user\` ONCE, passing every sibling's id in \`related_question_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.
178
190
 
179
- **Step 5 — Learn and finalize.** After every non-skip user answer that implies a generalizable rule (e.g. "Lazada on KTC Card Shopping"), call \`save_memory(content=<rule>, category="scanning_hint")\` so the next scan applies it silently. For merchant categorization, also call \`set_merchant_default_account\`. Phrase rules as reusable classifications, not one-event records (GOOD: "Lazada Thailand on KTC Card ••5678 expense:shopping." BAD: "On 2026-03-15 the user said Shopping.").
191
+ **Step 5 — Finalize.** After every non-skip user answer that resolves the question, apply the implied mutation (e.g. \`update_posting\`, \`merge_accounts\`, \`record_recurrence\`) and \`close_question\` with the user's answer. The pipeline derives a structural rule from the closed question automatically keyed on merchant id, normalized descriptor, or account pair from the question's stored context and upserts it into the rules table for the next scan. You do NOT call \`save_memory\` for scanner rules; that path is reserved for general facts, preferences, and life events.
180
192
 
181
- **Closing invariant.** Every question in the input list must be closed by the end. Closing deletes the row from the \`questions\` table. If anything is still open after step 4, close it with \`close_question(answer="Skip — could not interpret")\`. The pipeline reads the DB after you finish — if any question is still open it will re-invoke you with the leftovers, so always finish each row before yielding.
193
+ For merchant categorization, also call \`set_merchant_default_account\` so the merchant defaults table is updated alongside the structural rule.
182
194
 
183
- **Tool errors.** If a tool result comes back marked as an error (e.g. a malformed id, a row that no longer exists, a constraint violation), do NOT call \`close_question\` for the affected row. Either fix the input and retry the same mutation, or close that one row with \`close_question(answer="Skip tool error: <short reason>")\` so the loop can move on. Never close a row whose underlying mutation failed.
195
+ **The four outcomes (preference order: Resolved > Deferred > Left open > Skipped).** Every question hands off in exactly one of these states. Pick the closest fit; do NOT collapse "I lack info today" into a Skip.
196
+
197
+ 1. **Resolved.** \`close_question(answer=<real answer>)\`. The user gave a concrete answer and you applied the mutation. Synthesizes a structural rule for the next scan.
198
+ 2. **Deferred.** \`defer_question(question_id, days=N)\`. You genuinely lack the info today and another scan, another conversation, or the user's own memory may surface it later. The row stays in the table but is hidden from the next \`plasalid clarify\` run for N days. Default N=7. Use shorter (1–2) when the user said "ask me tomorrow" or "let me check"; longer (30+) for genuinely seasonal data (annual statement, year-end review). Prefer Deferred over Skipped whenever the question is still worth answering eventually.
199
+ 3. **Left open.** No tool call. The question persists exactly as-is and the next clarify run sees it again. Use sparingly — only when you've taken a real swing at it this run and want the next run to try too, without a specific delay. The converge loop stops cleanly when nothing changes, so leaving rows open does not stall the system.
200
+ 4. **Skipped.** \`close_question(answer="Skip — leave as is")\`. The user explicitly said skip, OR the underlying entity is genuinely gone (tool error on an id that no longer exists). One-time recovery decision — does NOT become a rule and will not be replayed. Use this when the right action is "do nothing about this row, ever". This is the **last resort**; if you find yourself reaching for Skip because you ran out of ideas, choose Deferred instead.
201
+
202
+ **Tool errors.** If a tool result comes back marked as an error (e.g. a malformed id, a row that no longer exists, a constraint violation), do NOT call \`close_question\` for the affected row. Either fix the input and retry the same mutation, or — if the underlying entity is genuinely gone — Skip the row (per outcome 4). If the error is transient or you suspect retrying later might succeed, Defer instead. Never close a row whose underlying mutation failed silently.
184
203
 
185
204
  Question kind → mutation tool map (use after a user answer in step 4):
186
- - \`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\`.
205
+ - \`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\`. The rule stored is "descriptor or merchant → account". When the same descriptor or merchant appears on other past postings still sitting in an uncategorized account, also call \`bulk_update_postings\` once (filter by \`merchant_id\` or \`description_contains\` plus the uncategorized \`account_id\`) so past data matches the rule you just learned — don't leave the user to clean up by hand.
206
+ - \`unknown_merchant\` → confirm the merchant via \`find_or_create_merchant\` so it exists for future scans. The rule stored is "descriptor → merchant canonical name"; the transaction's existing merchant_id stays NULL (re-link via \`plasalid record\` if needed).
207
+ - \`similar_accounts\` → "Merge A into B" / "Merge B into A" → \`merge_accounts(from_id, to_id)\`. "Keep separate" / "Skip" → no mutation. The rule stored is "account-pair → merge direction or keep-separate".
187
208
  - \`duplicate\` → "Delete this one" → \`delete_transaction\` on the question's transaction_id. "Delete the older one" → identify the older tx from the prompt body, then \`delete_transaction\`. "Keep both" / "Skip" → no mutation.
188
209
  - \`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.
189
210
  - \`recurrence_candidate\` → "Link as recurring" → \`record_recurrence\` with the candidate's transaction_ids and the implied frequency. "Not recurring" / "Skip" → no mutation.
190
- - \`similar_accounts\` → "Merge A into B" / "Merge B into A" \`merge_accounts(from_id, to_id)\`. "Keep separate" / "Skip" no mutation.
211
+ - \`dirty_input\` → these are AI-output validation failures (no date, malformed amount). Not auto-resolvable. Close with \`Skip leave as is\`; the user can re-enter via \`plasalid record\` if the row matters.
212
+
213
+ **Wiping a whole scanned file.** If the user explicitly asks to redo a file ("this scan came out wrong, drop it and I'll re-scan with a different model", "delete the march statement"), use \`delete_scanned_file(file_id)\`. The cascade removes every transaction and question tied to that file in one shot. Two rules: (1) confirm with the user before calling — cascading deletes are irreversible. (2) Resolve the file by id, not by guess: every question carries a \`file_id\` in its context line; if the user names a file you don't see in the question list, call \`list_scanned_files\` first to find the id. Never call \`delete_scanned_file\` to resolve an individual question — that's what \`close_question\` is for.
191
214
 
192
215
  How to phrase \`ask_user\`:
193
216
  - Use the question's \`prompt\` verbatim (or a tightened version when grouping). Don't restate amounts/dates/accounts in prose — that's what \`facts\` is for.
@@ -46,3 +46,13 @@ export interface ScopeOptions {
46
46
  export declare function renderScope(opts: ScopeOptions): string;
47
47
  /** Chat user context */
48
48
  export declare function renderUserContext(name: string, contextMd: string | null): string;
49
+ /** Rules (structured scanner hints) */
50
+ export declare function renderRules(db: Database.Database, header: string): string | null;
51
+ /** Open clarify-questions backlog (chat surface) */
52
+ /**
53
+ * Emit a discreet hint about open clarify questions when the backlog is
54
+ * non-empty. The chat agent decides when to mention it based on the user's
55
+ * message — don't volunteer the count out of context. Returns null when the
56
+ * backlog is empty so `joinSections` drops the slot entirely.
57
+ */
58
+ export declare function renderOpenQuestionsHint(db: Database.Database): string | null;
@@ -1,4 +1,6 @@
1
1
  import { getMemories } from "./memory.js";
2
+ import { listRules } from "../db/queries/rules.js";
3
+ import { countQuestions } from "../db/queries/questions.js";
2
4
  import { getAccountBalances, } from "../db/queries/account-balance.js";
3
5
  import { stripControls } from "./sanitize.js";
4
6
  /**
@@ -105,3 +107,30 @@ function formatMemoryLine(m, showCategory) {
105
107
  ? `- [${m.category}] ${stripControls(m.content)}`
106
108
  : `- ${stripControls(m.content)}`;
107
109
  }
110
+ /** Rules (structured scanner hints) */
111
+ export function renderRules(db, header) {
112
+ const rules = listRules(db, { limit: 500 });
113
+ if (rules.length === 0)
114
+ return null;
115
+ const lines = rules.map(formatRuleLine);
116
+ return `## ${header}\n${lines.join("\n")}`;
117
+ }
118
+ function formatRuleLine(r) {
119
+ return `- [${r.kind}] ${stripControls(r.key)} -> ${stripControls(r.target)}`;
120
+ }
121
+ /** Open clarify-questions backlog (chat surface) */
122
+ /**
123
+ * Emit a discreet hint about open clarify questions when the backlog is
124
+ * non-empty. The chat agent decides when to mention it based on the user's
125
+ * message — don't volunteer the count out of context. Returns null when the
126
+ * backlog is empty so `joinSections` drops the slot entirely.
127
+ */
128
+ export function renderOpenQuestionsHint(db) {
129
+ const n = countQuestions(db);
130
+ if (n === 0)
131
+ return null;
132
+ return [
133
+ "## Open clarify questions",
134
+ `There ${n === 1 ? "is 1 open question" : `are ${n} open questions`} in the backlog. Mention this only when the user's message is related (e.g. they ask about uncategorized spending, a specific merchant, or "what's pending"); don't volunteer it otherwise. When you do mention it, suggest \`plasalid clarify\`.`,
135
+ ].join("\n");
136
+ }
@@ -2,7 +2,7 @@ import { config } from "../config.js";
2
2
  import { readContext } from "./context.js";
3
3
  import { chatPersona, SCAN_PERSONA, CLARIFY_PERSONA, RECORD_PERSONA } from "./personas.js";
4
4
  import { getThaiTaxonomyHint } from "../accounts/taxonomy.js";
5
- import { renderChartOfAccounts, renderChatChartOrEmpty, renderMemories, renderScope, renderTodayHuman, renderTodayIso, renderUserContext, } from "./prompt-sections.js";
5
+ import { renderChartOfAccounts, renderChatChartOrEmpty, renderMemories, renderOpenQuestionsHint, renderRules, renderScope, renderTodayHuman, renderTodayIso, renderUserContext, } from "./prompt-sections.js";
6
6
  /**
7
7
  * Builders
8
8
  *
@@ -17,6 +17,7 @@ export function buildChatSystemPrompt(db) {
17
17
  renderTodayHuman(),
18
18
  renderUserContext(name, readContext()),
19
19
  renderChatChartOrEmpty(db, name),
20
+ renderOpenQuestionsHint(db),
20
21
  renderMemories(db, {
21
22
  header: `Things to remember about ${name}`,
22
23
  showCategory: true,
@@ -29,8 +30,9 @@ export function buildClarifySystemPrompt(db, opts) {
29
30
  renderTodayIso(),
30
31
  renderChartOfAccounts(db, { withBalance: true, emptyState: "clarify" }),
31
32
  renderScope(opts),
33
+ renderRules(db, "Rules you've already learned (apply directly; do not re-ask the user)"),
32
34
  renderMemories(db, {
33
- header: "Rules you've already learned (apply directly; do not re-ask the user)",
35
+ header: "User memory (general facts, preferences, life events)",
34
36
  showCategory: true,
35
37
  }),
36
38
  ]);
@@ -41,9 +43,10 @@ export function buildRecordSystemPrompt(db, opts) {
41
43
  renderTodayIso(),
42
44
  renderChartOfAccounts(db, { withBalance: true, emptyState: "scan" }),
43
45
  `## What the user said\n> ${opts.utterance.replace(/\n/g, " ")}`,
46
+ renderRules(db, "Rules you've already learned (apply silently)"),
44
47
  renderMemories(db, {
45
- header: "Rules you've already learned (apply silently)",
46
- filterCategories: ["scanning_hint", "general", "preference"],
48
+ header: "User memory (general facts, preferences)",
49
+ filterCategories: ["general", "preference"],
47
50
  showCategory: false,
48
51
  }),
49
52
  ]);
@@ -55,9 +58,10 @@ export function buildScanSystemPrompt(db, opts) {
55
58
  renderChartOfAccounts(db, { withBalance: false, emptyState: "scan" }),
56
59
  `## File context\nFile: ${opts.fileName}`,
57
60
  `## Taxonomy hints\n${getThaiTaxonomyHint()}`,
61
+ renderRules(db, "Rules you've already learned (apply silently before raising a question)"),
58
62
  renderMemories(db, {
59
- header: "Rules you've already learned (apply silently before raising a question)",
60
- filterCategories: ["scanning_hint", "general"],
63
+ header: "User memory (general facts)",
64
+ filterCategories: ["general"],
61
65
  showCategory: false,
62
66
  }),
63
67
  ]);
@@ -1,6 +1,7 @@
1
1
  import { deleteTransaction, updateTransaction, updatePosting, } from "../../db/queries/transactions.js";
2
2
  import { mergeAccounts } from "../../db/queries/account-balance.js";
3
3
  import { linkTransactionToRecurrence, recordRecurrence, } from "../../db/queries/recurrences.js";
4
+ import { deleteScannedFile, listScannedFiles } from "../../db/queries/files.js";
4
5
  import { sanitizeForPrompt } from "../sanitize.js";
5
6
  const DEFS = [
6
7
  {
@@ -96,6 +97,20 @@ const DEFS = [
96
97
  required: ["from_id", "to_id"],
97
98
  },
98
99
  },
100
+ {
101
+ name: "list_scanned_files",
102
+ description: "List every scanned_files row with id, path, status, provider, model, and scanned_at. Use to resolve a file the user mentions by name (e.g. 'drop march-statement.pdf') into a file_id before calling delete_scanned_file. Returns at most 200 rows, newest first.",
103
+ input_schema: { type: "object", properties: {}, required: [] },
104
+ },
105
+ {
106
+ name: "delete_scanned_file",
107
+ description: "Delete a scanned_files row by id. Cascades to remove every transaction and question tied to that file (via ON DELETE CASCADE). Use ONLY when the user explicitly wants to drop a file's data so they can re-scan it with a different model — e.g. 'this scan came out wrong, let me redo it'. Never use to resolve a single question; that's `close_question`. Always confirm with the user before calling: cascading deletes are irreversible.",
108
+ input_schema: {
109
+ type: "object",
110
+ properties: { file_id: { type: "string" } },
111
+ required: ["file_id"],
112
+ },
113
+ },
99
114
  ];
100
115
  const LABELS = {
101
116
  update_transaction: "Updating transaction",
@@ -104,6 +119,8 @@ const LABELS = {
104
119
  record_recurrence: "Recording recurrence",
105
120
  link_transaction_to_recurrence: "Linking transaction to recurrence",
106
121
  merge_accounts: "Merging accounts",
122
+ list_scanned_files: "Listing scanned files",
123
+ delete_scanned_file: "Deleting scanned file",
107
124
  };
108
125
  async function execute(db, name, input, _ctx) {
109
126
  switch (name) {
@@ -162,6 +179,24 @@ async function execute(db, name, input, _ctx) {
162
179
  const moved = mergeAccounts(db, input.from_id, input.to_id);
163
180
  return `Merged ${input.from_id} → ${input.to_id}; moved ${moved} posting(s).`;
164
181
  }
182
+ case "list_scanned_files": {
183
+ const files = listScannedFiles(db).slice(0, 200);
184
+ if (files.length === 0)
185
+ return "No scanned files on record.";
186
+ return files
187
+ .map(f => {
188
+ const stamp = f.provider && f.model ? ` [${f.provider}/${f.model}]` : "";
189
+ const when = f.scanned_at ? ` · ${f.scanned_at}` : "";
190
+ return `${f.id} | ${sanitizeForPrompt(f.path)} | ${f.status}${stamp}${when}`;
191
+ })
192
+ .join("\n");
193
+ }
194
+ case "delete_scanned_file": {
195
+ const result = deleteScannedFile(db, input.file_id);
196
+ if (!result.removed)
197
+ return `Scanned file ${input.file_id} not found.`;
198
+ return `Deleted scanned file ${result.removed.path} (${input.file_id}); cascade removed ${result.removedTransactions} transaction(s) and ${result.removedQuestions} question(s).`;
199
+ }
165
200
  default:
166
201
  return undefined;
167
202
  }
@@ -22,14 +22,15 @@ const DEFS = [
22
22
  },
23
23
  {
24
24
  name: "save_memory",
25
- description: "Persist a fact or bank-specific scanning hint to long-term memory.",
25
+ description: "Persist a fact, preference, or life-event note to long-term memory. NOT for scanner rules — those are derived structurally from closed questions and live in the rules table.",
26
26
  input_schema: {
27
27
  type: "object",
28
28
  properties: {
29
29
  content: { type: "string", description: "What to remember." },
30
30
  category: {
31
31
  type: "string",
32
- description: "Category: general, scanning_hint, preference, life_event.",
32
+ description: "Category: general, preference, life_event.",
33
+ enum: ["general", "preference", "life_event"],
33
34
  default: "general",
34
35
  },
35
36
  },
@@ -5,6 +5,7 @@ import { scanTools } from "./scan.js";
5
5
  import { clarifyTools } from "./clarify.js";
6
6
  import { recordTools } from "./record.js";
7
7
  import { merchantTools } from "./merchants.js";
8
+ import { mutateTools } from "./mutate.js";
8
9
  /**
9
10
  * Profile composition. Each profile is the union of one or more tool modules;
10
11
  * the dispatcher iterates every module on each tool call so we never need a
@@ -12,9 +13,9 @@ import { merchantTools } from "./merchants.js";
12
13
  */
13
14
  const PROFILES = {
14
15
  scan: [commonTools, accountIngestTools, scanQuestionTools, scanTools, merchantTools],
15
- chat: [commonTools, readTools],
16
- clarify: [commonTools, readTools, accountIngestTools, clarifyIngestTools, clarifyTools, merchantTools],
17
- record: [commonTools, readTools, accountIngestTools, recordTools, merchantTools],
16
+ chat: [commonTools, readTools, accountIngestTools, clarifyTools, merchantTools, mutateTools],
17
+ clarify: [commonTools, readTools, accountIngestTools, clarifyIngestTools, clarifyTools, merchantTools, mutateTools],
18
+ record: [commonTools, readTools, accountIngestTools, recordTools, clarifyTools, merchantTools, mutateTools],
18
19
  };
19
20
  export function getToolDefinitions(profile) {
20
21
  return PROFILES[profile].flatMap(m => m.DEFS);
@@ -29,6 +30,7 @@ const MODULES = [
29
30
  clarifyTools,
30
31
  recordTools,
31
32
  merchantTools,
33
+ mutateTools,
32
34
  ];
33
35
  export async function executeTool(db, name, input, ctx) {
34
36
  try {
@@ -54,4 +56,5 @@ export const TOOL_LABELS = {
54
56
  ...clarifyTools.LABELS,
55
57
  ...recordTools.LABELS,
56
58
  ...merchantTools.LABELS,
59
+ ...mutateTools.LABELS,
57
60
  };
@@ -1,8 +1,9 @@
1
1
  import { createAccount, updateAccountMetadata, } from "../../db/queries/account-balance.js";
2
- import { validateTransaction, insertTransactionRows, recordTransaction, } from "../../db/queries/transactions.js";
3
- import { recordQuestion } from "../../db/queries/questions.js";
2
+ import { recordTransaction, } from "../../db/queries/transactions.js";
4
3
  import { runExclusive as runAccountExclusive } from "./account-mutex.js";
5
4
  import { ACCOUNT_TYPE_DESCRIPTIONS } from "../../accounts/taxonomy.js";
5
+ import { recordQuestion } from "../../db/queries/questions.js";
6
+ import { commitTransaction } from "../../scanner/committer.js";
6
7
  const ACCOUNT_TYPES = Object.keys(ACCOUNT_TYPE_DESCRIPTIONS);
7
8
  const BATCH_MAX = 50;
8
9
  const TRANSACTION_ITEM_SCHEMA = {
@@ -264,40 +265,23 @@ function buildTransactionInput(input, ctx) {
264
265
  })),
265
266
  };
266
267
  }
268
+ /**
269
+ * Thin adapter that wires the agent's execution context into the staged
270
+ * commit pipeline. The pipeline does best-effort resolution (NULL unknown
271
+ * merchant, fuzzy-match-or-create unknown account) and only drops a row on
272
+ * genuine validation failure. Failures raise typed questions rather than
273
+ * burning a "scan_commit_failure" memory rule.
274
+ */
267
275
  async function persistOneTransaction(db, ctx, txInput) {
268
- try {
269
- const validated = validateTransaction(txInput);
270
- const tx = db.transaction(() => {
271
- insertTransactionRows(db, validated);
272
- });
273
- tx();
274
- if (ctx.progress && ctx.chunkId) {
275
- ctx.progress.emit({ chunkId: ctx.chunkId, kind: "tx" });
276
- }
277
- return { ok: true, id: validated.id };
278
- }
279
- catch (err) {
280
- const message = err?.message ?? String(err);
281
- if (ctx.scanId) {
282
- try {
283
- recordQuestion(db, {
284
- file_id: ctx.fileId ?? null,
285
- scan_id: ctx.scanId,
286
- transaction_id: null,
287
- account_id: null,
288
- kind: "scan_commit_failure",
289
- prompt: `Could not record "${txInput.description}" on ${txInput.date}: ${message}. Review the source statement and re-enter via the record flow.`,
290
- });
291
- if (ctx.progress && ctx.chunkId) {
292
- ctx.progress.emit({ chunkId: ctx.chunkId, kind: "question" });
293
- }
294
- }
295
- catch {
296
- // failure to record a failure shouldn't crash the scan
297
- }
298
- }
299
- return { ok: false, error: message };
300
- }
276
+ const outcome = commitTransaction(db, {
277
+ scanId: ctx.scanId ?? null,
278
+ fileId: ctx.fileId ?? null,
279
+ chunkId: ctx.chunkId ?? null,
280
+ progress: ctx.progress ?? null,
281
+ }, txInput);
282
+ if (outcome.ok)
283
+ return { ok: true, id: outcome.transactionId };
284
+ return { ok: false, error: outcome.message };
301
285
  }
302
286
  async function accountExecute(db, name, input, ctx) {
303
287
  switch (name) {
@@ -529,14 +513,33 @@ const RESOLVE_DEFS = [
529
513
  required: ["question_id", "answer"],
530
514
  },
531
515
  },
516
+ {
517
+ name: "defer_question",
518
+ description: "Defer a question for `days` days. The row stays in the questions table but is hidden from `plasalid clarify` until the timestamp passes — the next run won't re-encounter it. Use when you genuinely lack info today and a future scan, a future conversation, or the user's own memory might surface the answer later. Prefer this over `close_question(answer=\"Skip — leave as is\")` whenever the question is still worth answering eventually.",
519
+ input_schema: {
520
+ type: "object",
521
+ properties: {
522
+ question_id: { type: "string" },
523
+ days: {
524
+ type: "number",
525
+ description: "Days to defer. Default 7. Use shorter (1-2) when the user said 'ask me tomorrow' or 'let me check'; longer (30+) for genuinely seasonal data like annual statements.",
526
+ default: 7,
527
+ },
528
+ },
529
+ required: ["question_id"],
530
+ },
531
+ },
532
532
  ];
533
533
  const RESOLVE_LABELS = {
534
534
  ask_user: "Asking for clarification",
535
535
  close_question: "Closing question",
536
+ defer_question: "Deferring question",
536
537
  };
537
538
  async function clarifyIngestExecute(db, name, input, ctx) {
538
539
  if (name === "close_question")
539
540
  return closeQuestionTool(db, input, ctx);
541
+ if (name === "defer_question")
542
+ return deferQuestionTool(db, input);
540
543
  if (name !== "ask_user")
541
544
  return undefined;
542
545
  if (!ctx)
@@ -567,6 +570,15 @@ async function clarifyIngestExecute(db, name, input, ctx) {
567
570
  }
568
571
  return `Awaiting user input — cannot proceed in non-interactive mode.`;
569
572
  }
573
+ async function deferQuestionTool(db, input) {
574
+ const { deferQuestion } = await import("../../db/queries/questions.js");
575
+ const id = String(input.question_id ?? "");
576
+ if (!id)
577
+ return "defer_question requires question_id.";
578
+ const days = Number.isFinite(input.days) ? Math.max(1, Math.floor(input.days)) : 7;
579
+ const updated = deferQuestion(db, id, days);
580
+ return updated ? `Deferred question ${id} for ${days} day${days === 1 ? "" : "s"}.` : `Question ${id} not found.`;
581
+ }
570
582
  async function closeQuestionTool(db, input, ctx) {
571
583
  const { closeQuestion } = await import("../../db/queries/questions.js");
572
584
  const primary = String(input.question_id ?? "");
@@ -0,0 +1,2 @@
1
+ import type { ToolModule } from "./types.js";
2
+ export declare const mutateTools: ToolModule;
@@ -0,0 +1,81 @@
1
+ import { bulkUpdatePostings, } from "../../db/queries/transactions.js";
2
+ const DEFS = [
3
+ {
4
+ name: "bulk_update_postings",
5
+ description: "Recategorize (and/or re-memo) every posting matching the filter in a single call. " +
6
+ "Use this when the user confirms a categorization rule for past data " +
7
+ "(e.g. \"every salary from บริษัท คริปโตมายด์ should be income:salary\"). " +
8
+ "Pair this with save_memory so the rule also persists for future sessions. " +
9
+ "For amount/currency corrections, delete the transaction and re-record it instead.",
10
+ input_schema: {
11
+ type: "object",
12
+ properties: {
13
+ filter: {
14
+ type: "object",
15
+ description: "At least one field is required.",
16
+ properties: {
17
+ account_id: {
18
+ type: "string",
19
+ description: "Current account (e.g. income:uncategorized).",
20
+ },
21
+ description_contains: {
22
+ type: "string",
23
+ description: "Case-insensitive substring match against the transaction description. " +
24
+ "Use multiple calls for descriptor variants (no regex).",
25
+ },
26
+ currency: { type: "string" },
27
+ from: { type: "string", description: "ISO date (inclusive)." },
28
+ to: { type: "string", description: "ISO date (inclusive)." },
29
+ merchant_id: { type: "string" },
30
+ },
31
+ },
32
+ set: {
33
+ type: "object",
34
+ description: "At least one field is required.",
35
+ properties: {
36
+ account_id: {
37
+ type: "string",
38
+ description: "New account_id to assign to every matching posting.",
39
+ },
40
+ memo: {
41
+ type: "string",
42
+ description: "New memo to apply to every matching posting.",
43
+ },
44
+ },
45
+ },
46
+ },
47
+ required: ["filter", "set"],
48
+ },
49
+ },
50
+ ];
51
+ const LABELS = {
52
+ bulk_update_postings: "Backfilling postings",
53
+ };
54
+ async function execute(db, name, input, _ctx) {
55
+ if (name !== "bulk_update_postings")
56
+ return undefined;
57
+ try {
58
+ const filter = (input?.filter ?? {});
59
+ const set = (input?.set ?? {});
60
+ const result = bulkUpdatePostings(db, filter, set);
61
+ if (result.affected === 0) {
62
+ return "No postings matched the filter; nothing changed.";
63
+ }
64
+ const targetSummary = describeSet(set);
65
+ const sample = result.sample_posting_ids.join(", ");
66
+ return (`Updated ${result.affected} posting(s) → ${targetSummary}. ` +
67
+ `Sample ids: ${sample}.`);
68
+ }
69
+ catch (err) {
70
+ return `Could not bulk update postings: ${err?.message ?? String(err)}`;
71
+ }
72
+ }
73
+ function describeSet(set) {
74
+ const parts = [];
75
+ if (set.account_id !== undefined)
76
+ parts.push(`account_id=${set.account_id}`);
77
+ if (set.memo !== undefined)
78
+ parts.push(`memo=${JSON.stringify(set.memo)}`);
79
+ return parts.join(", ") || "(no changes)";
80
+ }
81
+ export const mutateTools = { DEFS, LABELS, execute };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Open the scanned-files browser. The user-facing surface for dropping a
3
+ * file's data — same `d`-confirm-`y/n` loop as the rules browser, but
4
+ * typed for scanned_files rows so the layout shows path / status /
5
+ * provider / model / scanned_at.
6
+ */
7
+ export declare function showFiles(): Promise<void>;
@@ -0,0 +1,24 @@
1
+ import chalk from "chalk";
2
+ import { getDb } from "../../db/connection.js";
3
+ import { listScannedFiles } from "../../db/queries/files.js";
4
+ /**
5
+ * Open the scanned-files browser. The user-facing surface for dropping a
6
+ * file's data — same `d`-confirm-`y/n` loop as the rules browser, but
7
+ * typed for scanned_files rows so the layout shows path / status /
8
+ * provider / model / scanned_at.
9
+ */
10
+ export async function showFiles() {
11
+ const db = getDb();
12
+ const files = listScannedFiles(db);
13
+ if (files.length === 0) {
14
+ console.log("No scanned files yet.\n\n" +
15
+ chalk.dim("Drop PDFs into ~/.plasalid/data/ and run `plasalid scan`."));
16
+ return;
17
+ }
18
+ const [{ runBrowser }, { FilesBrowser }, { createElement }] = await Promise.all([
19
+ import("../ink/runBrowser.js"),
20
+ import("../ink/FilesBrowser.js"),
21
+ import("react"),
22
+ ]);
23
+ await runBrowser(createElement(FilesBrowser, { files, db }));
24
+ }
@@ -1,29 +1,32 @@
1
1
  import chalk from "chalk";
2
2
  import { getDb } from "../../db/connection.js";
3
3
  import { getMemories, deleteMemory } from "../../ai/memory.js";
4
+ import { listRules, deleteRule } from "../../db/queries/rules.js";
4
5
  import { listMerchants, clearMerchantDefaultAccount, } from "../../db/queries/merchants.js";
5
6
  export function collectRules(db) {
6
- const out = [];
7
- for (const m of getMemories(db)) {
8
- out.push({
9
- displayId: `mem:${m.id}`,
10
- text: m.content,
11
- forget: (db) => {
12
- deleteMemory(db, m.id);
13
- },
14
- });
15
- }
7
+ return [...collectStructuredRules(db), ...collectMemories(db), ...collectMerchantDefaults(db)];
8
+ }
9
+ function collectStructuredRules(db) {
10
+ return listRules(db).map((r) => ({
11
+ displayId: `rule:${r.id}`,
12
+ text: `[${r.kind}] ${r.key} → ${r.target}`,
13
+ forget: (db) => { deleteRule(db, r.id); },
14
+ }));
15
+ }
16
+ function collectMemories(db) {
17
+ return getMemories(db).map((m) => ({
18
+ displayId: `mem:${m.id}`,
19
+ text: m.content,
20
+ forget: (db) => { deleteMemory(db, m.id); },
21
+ }));
22
+ }
23
+ function collectMerchantDefaults(db) {
16
24
  const merchants = listMerchants(db, { withDefaultOnly: true });
17
- merchants.forEach((m, i) => {
18
- out.push({
19
- displayId: `mch:${i + 1}`,
20
- text: `${m.canonical_name} ${m.default_account_id}`,
21
- forget: (db) => {
22
- clearMerchantDefaultAccount(db, m.id);
23
- },
24
- });
25
- });
26
- return out;
25
+ return merchants.map((m, i) => ({
26
+ displayId: `mch:${i + 1}`,
27
+ text: `${m.canonical_name} ${m.default_account_id}`,
28
+ forget: (db) => { clearMerchantDefaultAccount(db, m.id); },
29
+ }));
27
30
  }
28
31
  export async function showRules() {
29
32
  const db = getDb();