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
@@ -1,6 +1,6 @@
1
- import { findAccountById, getAccountBalances, getNetWorth, getPeriodTotals, } from "../../db/queries/account_balance.js";
1
+ import { findAccountById, getAccountBalances, getNetWorth, getPeriodTotals, } from "../../db/queries/account-balance.js";
2
2
  import { listPostings } from "../../db/queries/transactions.js";
3
- import { listOpenConcerns } from "../../db/queries/concerns.js";
3
+ import { listOpenUnknowns } from "../../db/queries/unknowns.js";
4
4
  import { searchPostings } from "../../db/queries/search.js";
5
5
  import { formatAmount } from "../../currency.js";
6
6
  import { sanitizeForPrompt, sanitizeForPromptCell } from "../sanitize.js";
@@ -28,7 +28,10 @@ const DEFS = [
28
28
  account_id: { type: "string" },
29
29
  from: { type: "string", description: "Start date YYYY-MM-DD" },
30
30
  to: { type: "string", description: "End date YYYY-MM-DD" },
31
- q: { type: "string", description: "Free-text contains-match on description, memo, or merchant" },
31
+ q: {
32
+ type: "string",
33
+ description: "Free-text contains-match on description, memo, or merchant",
34
+ },
32
35
  limit: { type: "number", description: "Max results (default 50)" },
33
36
  },
34
37
  required: [],
@@ -59,13 +62,16 @@ const DEFS = [
59
62
  },
60
63
  },
61
64
  {
62
- name: "list_open_concerns",
63
- description: "List clarification requests recorded by the scanner that have not been resolved yet. Each row carries the prompt, optional candidate answers, and the file/transaction/account it was attached to. The reviewer uses this to drive the step-by-step clarification loop.",
65
+ name: "list_open_unknowns",
66
+ description: "List clarification requests recorded by the scanner that have not been resolved yet. Each row carries the prompt, optional candidate answers, and the file/transaction/account it was attached to. The resolver uses this to drive the step-by-step clarification loop.",
64
67
  input_schema: {
65
68
  type: "object",
66
69
  properties: {
67
70
  limit: { type: "number", default: 50 },
68
- kind: { type: "string", description: "Optional filter by concern kind (e.g. 'uncategorized_expense')." },
71
+ kind: {
72
+ type: "string",
73
+ description: "Optional filter by unknown kind (e.g. 'uncategorized_expense').",
74
+ },
69
75
  },
70
76
  required: [],
71
77
  },
@@ -77,7 +83,7 @@ const LABELS = {
77
83
  list_postings: "Listing postings",
78
84
  search_transactions: "Searching transactions",
79
85
  get_period_totals: "Summing period totals",
80
- list_open_concerns: "Listing open concerns",
86
+ list_open_unknowns: "Listing open unknowns",
81
87
  };
82
88
  async function execute(db, name, input, _ctx) {
83
89
  switch (name) {
@@ -86,7 +92,7 @@ async function execute(db, name, input, _ctx) {
86
92
  if (!acct)
87
93
  return `Account "${input.account_id}" not found.`;
88
94
  const balances = getAccountBalances(db);
89
- const bal = balances.find(b => b.id === acct.id)?.balance ?? 0;
95
+ const bal = balances.find((b) => b.id === acct.id)?.balance ?? 0;
90
96
  return `${sanitizeForPrompt(acct.name)} (${acct.type}): ${formatAmount(bal)}`;
91
97
  }
92
98
  case "get_net_worth": {
@@ -104,10 +110,12 @@ async function execute(db, name, input, _ctx) {
104
110
  if (rows.length === 0)
105
111
  return "No matching postings.";
106
112
  return rows
107
- .map(r => {
113
+ .map((r) => {
108
114
  const dr = r.debit > 0 ? `DR ${formatAmount(r.debit)}` : "";
109
115
  const cr = r.credit > 0 ? `CR ${formatAmount(r.credit)}` : "";
110
- const merchant = r.merchant_name ? ` (${sanitizeForPromptCell(r.merchant_name)})` : "";
116
+ const merchant = r.merchant_name
117
+ ? ` (${sanitizeForPromptCell(r.merchant_name)})`
118
+ : "";
111
119
  return `${r.transaction_date} | ${sanitizeForPromptCell(r.transaction_description || "")}${merchant} | ${sanitizeForPromptCell(r.account_name || "")} | ${dr}${cr} | ${sanitizeForPromptCell(r.memo || "")}`;
112
120
  })
113
121
  .join("\n");
@@ -117,10 +125,12 @@ async function execute(db, name, input, _ctx) {
117
125
  if (rows.length === 0)
118
126
  return `No matches for "${sanitizeForPrompt(input.query)}".`;
119
127
  return rows
120
- .map(r => {
128
+ .map((r) => {
121
129
  const dr = r.debit > 0 ? `DR ${formatAmount(r.debit)}` : "";
122
130
  const cr = r.credit > 0 ? `CR ${formatAmount(r.credit)}` : "";
123
- const merchant = r.merchant_name ? ` (${sanitizeForPromptCell(r.merchant_name)})` : "";
131
+ const merchant = r.merchant_name
132
+ ? ` (${sanitizeForPromptCell(r.merchant_name)})`
133
+ : "";
124
134
  return `${r.transaction_date} | ${sanitizeForPromptCell(r.transaction_description || "")}${merchant} | ${sanitizeForPromptCell(r.account_name || "")} | ${dr}${cr}`;
125
135
  })
126
136
  .join("\n");
@@ -129,21 +139,27 @@ async function execute(db, name, input, _ctx) {
129
139
  const totals = getPeriodTotals(db, input.from, input.to);
130
140
  return `Income ${formatAmount(totals.income)} · Expenses ${formatAmount(totals.expenses)} · Net ${formatAmount(totals.income - totals.expenses)}`;
131
141
  }
132
- case "list_open_concerns": {
133
- const rows = listOpenConcerns(db, input.limit ?? 50);
134
- const filtered = input.kind ? rows.filter(r => r.kind === input.kind) : rows;
142
+ case "list_open_unknowns": {
143
+ const rows = listOpenUnknowns(db, input.limit ?? 50);
144
+ const filtered = input.kind
145
+ ? rows.filter((r) => r.kind === input.kind)
146
+ : rows;
135
147
  if (filtered.length === 0)
136
- return "No open concerns. The picture is clear.";
148
+ return "No open unknowns. The picture is clear.";
137
149
  return filtered
138
- .map(r => {
150
+ .map((r) => {
139
151
  const targets = [
140
152
  r.transaction_id ? `transaction=${r.transaction_id}` : null,
141
153
  r.account_id ? `account=${r.account_id}` : null,
142
- !r.transaction_id && !r.account_id && r.file_id ? `file=${r.file_id}` : null,
154
+ !r.transaction_id && !r.account_id && r.file_id
155
+ ? `file=${r.file_id}`
156
+ : null,
143
157
  r.kind ? `kind=${r.kind}` : null,
144
- ].filter(Boolean).join(" ");
158
+ ]
159
+ .filter(Boolean)
160
+ .join(" ");
145
161
  const options = r.options_json
146
- ? ` [options: ${JSON.parse(r.options_json).map(o => sanitizeForPrompt(o)).join(" | ")}]`
162
+ ? ` [options: ${JSON.parse(r.options_json).map((o) => sanitizeForPrompt(o)).join(" | ")}]`
147
163
  : "";
148
164
  return `${r.id} ${targets} — ${sanitizeForPrompt(r.prompt)}${options}`;
149
165
  })
@@ -1,6 +1,6 @@
1
- import { findAccountById, findAccountsByFuzzyName, getAccountBalances, ensureStructuralAccount, } from "../../db/queries/account_balance.js";
1
+ import { findAccountById, findAccountsByFuzzyName, getAccountBalances, ensureStructuralAccount, renameAccount, deleteAccount, } from "../../db/queries/account-balance.js";
2
2
  import { validateTransaction, insertTransactionRows, } from "../../db/queries/transactions.js";
3
- import { appendAction } from "../../db/queries/action_log.js";
3
+ import { appendAction } from "../../db/queries/action-log.js";
4
4
  import { formatAmount } from "../../currency.js";
5
5
  import { sanitizeForPrompt } from "../sanitize.js";
6
6
  const EQUITY_ADJUST_ID = "equity:adjustments";
@@ -10,10 +10,10 @@ function todayIso() {
10
10
  /**
11
11
  * Record-only tool definitions
12
12
  *
13
- * `find_similar_accounts` and `clarify` are reads / prompts; only
14
- * `adjust_account_balance` mutates the DB. It writes its own action_log row
15
- * with `action_type='adjust_balance'` rather than going through the shared
16
- * `record_transaction` path.
13
+ * `find_similar_accounts` and `clarify` are reads / prompts; `adjust_account_balance`,
14
+ * `rename_account`, and `delete_account` mutate the DB. Of those, only
15
+ * `adjust_account_balance` writes an action_log row (with `action_type='adjust_balance'`);
16
+ * rename and delete are simple shape changes without an audit entry.
17
17
  */
18
18
  const DEFS = [
19
19
  {
@@ -22,8 +22,15 @@ const DEFS = [
22
22
  input_schema: {
23
23
  type: "object",
24
24
  properties: {
25
- query: { type: "string", description: "Free-text name to match against the chart of accounts." },
26
- threshold: { type: "number", description: "Minimum similarity (0-1). Default 0.5.", default: 0.5 },
25
+ query: {
26
+ type: "string",
27
+ description: "Free-text name to match against the chart of accounts.",
28
+ },
29
+ threshold: {
30
+ type: "number",
31
+ description: "Minimum similarity (0-1). Default 0.5.",
32
+ default: 0.5,
33
+ },
27
34
  },
28
35
  required: ["query"],
29
36
  },
@@ -35,20 +42,53 @@ const DEFS = [
35
42
  type: "object",
36
43
  properties: {
37
44
  account_id: { type: "string" },
38
- target_balance: { type: "number", description: "The new desired balance in the account's currency, in natural sign (positive)." },
39
- reason: { type: "string", description: "Short description, e.g. 'Set DIEM portfolio to current market value (user-asserted).'." },
40
- date: { type: "string", description: "ISO YYYY-MM-DD. Defaults to today." },
45
+ target_balance: {
46
+ type: "number",
47
+ description: "The new desired balance in the account's currency, in natural sign (positive).",
48
+ },
49
+ reason: {
50
+ type: "string",
51
+ description: "Short description, e.g. 'Set DIEM portfolio to current market value (user-asserted).'.",
52
+ },
53
+ date: {
54
+ type: "string",
55
+ description: "ISO YYYY-MM-DD. Defaults to today.",
56
+ },
41
57
  },
42
58
  required: ["account_id", "target_balance", "reason"],
43
59
  },
44
60
  },
61
+ {
62
+ name: "rename_account",
63
+ description: "Rename an existing account. Postings and metadata are untouched. Use for utterances like 'rename SCB to Bangkok Bank' once the user-named account is resolved via find_similar_accounts.",
64
+ input_schema: {
65
+ type: "object",
66
+ properties: {
67
+ account_id: { type: "string" },
68
+ name: { type: "string" },
69
+ },
70
+ required: ["account_id", "name"],
71
+ },
72
+ },
73
+ {
74
+ name: "delete_account",
75
+ description: "Delete an account that has no postings and no children. Use for utterances like 'delete my old empty cash account'. Refuses if the account still has postings (merge into another account first) or child accounts (delete or re-parent the children first).",
76
+ input_schema: {
77
+ type: "object",
78
+ properties: { account_id: { type: "string" } },
79
+ required: ["account_id"],
80
+ },
81
+ },
45
82
  {
46
83
  name: "clarify",
47
- description: "Ask the user a clarifying question and return their answer as a string. Use when the utterance is ambiguous (multiple matching accounts, missing amount, unclear date, can't tell expense vs transfer, plan confirmation before a multi-step action). Unlike review's ask_user, this does NOT write to the concerns table — record-time questions are transient.",
84
+ description: "Ask the user a clarifying question and return their answer as a string. Use when the utterance is ambiguous (multiple matching accounts, missing amount, unclear date, can't tell expense vs transfer, plan confirmation before a multi-step action). Unlike resolve's ask_user, this does NOT write to the unknowns table — record-time questions are transient.",
48
85
  input_schema: {
49
86
  type: "object",
50
87
  properties: {
51
- prompt: { type: "string", description: "The question to ask in plain language." },
88
+ prompt: {
89
+ type: "string",
90
+ description: "The question to ask in plain language.",
91
+ },
52
92
  options: {
53
93
  type: "array",
54
94
  items: { type: "string" },
@@ -72,6 +112,8 @@ const DEFS = [
72
112
  const LABELS = {
73
113
  find_similar_accounts: "Searching similar accounts",
74
114
  adjust_account_balance: "Adjusting balance",
115
+ rename_account: "Renaming account",
116
+ delete_account: "Deleting account",
75
117
  clarify: "Asking for clarification",
76
118
  };
77
119
  async function execute(db, name, input, ctx) {
@@ -82,11 +124,26 @@ async function execute(db, name, input, ctx) {
82
124
  return `No accounts matched "${sanitizeForPrompt(input.query ?? "")}".`;
83
125
  return matches
84
126
  .slice(0, 8)
85
- .map(m => `${m.account.id} | ${sanitizeForPrompt(m.account.name)} | ${m.account.type}${m.account.subtype ? `/${m.account.subtype}` : ""} | similarity ${m.similarity}`)
127
+ .map((m) => `${m.account.id} | ${sanitizeForPrompt(m.account.name)} | ${m.account.type}${m.account.subtype ? `/${m.account.subtype}` : ""} | similarity ${m.similarity}`)
86
128
  .join("\n");
87
129
  }
88
130
  case "adjust_account_balance":
89
131
  return adjustAccountBalance(db, input, ctx);
132
+ case "rename_account": {
133
+ const changed = renameAccount(db, input.account_id, input.name);
134
+ return changed === 0
135
+ ? `Account ${input.account_id} not found.`
136
+ : `Renamed ${input.account_id} → "${sanitizeForPrompt(input.name)}".`;
137
+ }
138
+ case "delete_account": {
139
+ try {
140
+ deleteAccount(db, input.account_id);
141
+ return `Deleted account ${input.account_id}.`;
142
+ }
143
+ catch (err) {
144
+ return `Could not delete: ${err.message}`;
145
+ }
146
+ }
90
147
  case "clarify": {
91
148
  if (!ctx)
92
149
  return "clarify is only available inside an agent session.";
@@ -103,8 +160,6 @@ async function execute(db, name, input, ctx) {
103
160
  async function adjustAccountBalance(db, input, ctx) {
104
161
  if (!ctx)
105
162
  return "adjust_account_balance is only available inside an agent session.";
106
- if (ctx.dryRun)
107
- return `Would adjust ${input.account_id} balance to ${input.target_balance}.`;
108
163
  const account = findAccountById(db, input.account_id);
109
164
  if (!account)
110
165
  return `Account "${input.account_id}" not found.`;
@@ -112,7 +167,7 @@ async function adjustAccountBalance(db, input, ctx) {
112
167
  if (!Number.isFinite(target))
113
168
  return `target_balance must be a number, got ${JSON.stringify(input.target_balance)}.`;
114
169
  const balances = getAccountBalances(db);
115
- const current = balances.find(b => b.id === account.id)?.balance ?? 0;
170
+ const current = balances.find((b) => b.id === account.id)?.balance ?? 0;
116
171
  const delta = round2(target - current);
117
172
  if (delta === 0) {
118
173
  return `${sanitizeForPrompt(account.name)} is already at ${formatAmount(target)}; no transaction posted.`;
@@ -123,7 +178,9 @@ async function adjustAccountBalance(db, input, ctx) {
123
178
  ? account.id
124
179
  : EQUITY_ADJUST_ID;
125
180
  const creditAccountId = debitAccountId === account.id ? EQUITY_ADJUST_ID : account.id;
126
- const date = input.date && /^\d{4}-\d{2}-\d{2}$/.test(input.date) ? input.date : todayIso();
181
+ const date = input.date && /^\d{4}-\d{2}-\d{2}$/.test(input.date)
182
+ ? input.date
183
+ : todayIso();
127
184
  const reason = String(input.reason || "Balance adjustment").trim();
128
185
  const currency = account.currency || "THB";
129
186
  const txInput = {
@@ -169,7 +226,10 @@ async function adjustAccountBalance(db, input, ctx) {
169
226
  account_id: account.id,
170
227
  before_balance: current,
171
228
  after_balance: target,
172
- transaction: { date: validated.date, description: validated.description },
229
+ transaction: {
230
+ date: validated.date,
231
+ description: validated.description,
232
+ },
173
233
  postings: validated.postings,
174
234
  },
175
235
  });
@@ -0,0 +1,2 @@
1
+ import type { ToolModule } from "./types.js";
2
+ export declare const resolveTools: ToolModule;
@@ -0,0 +1,195 @@
1
+ import { deleteTransaction, updateTransaction, updatePosting, } from "../../db/queries/transactions.js";
2
+ import { mergeAccounts } from "../../db/queries/account-balance.js";
3
+ import { linkTransactionToRecurrence, recordRecurrence, } from "../../db/queries/recurrences.js";
4
+ import { sanitizeForPrompt } from "../sanitize.js";
5
+ /**
6
+ * Resolve-mode tools: the mutation primitives an agent calls to APPLY the
7
+ * answer to an open unknown. Inspection has already happened (scanner inspectors
8
+ * wrote the unknowns); discovery tools (find_duplicate_transactions,
9
+ * find_recurrences, etc.) don't live here — the resolver iterates unknowns
10
+ * and asks the user, it doesn't search.
11
+ */
12
+ const DEFS = [
13
+ {
14
+ name: "update_transaction",
15
+ description: "Header-only update: date, description, or source_page. To change amounts, delete the transaction and record a new one.",
16
+ input_schema: {
17
+ type: "object",
18
+ properties: {
19
+ transaction_id: { type: "string" },
20
+ date: { type: "string" },
21
+ description: { type: "string" },
22
+ source_page: { type: "number" },
23
+ },
24
+ required: ["transaction_id"],
25
+ },
26
+ },
27
+ {
28
+ name: "update_posting",
29
+ description: "Safe single-posting edit: re-categorize (account_id) or update memo. Refuses changes to debit/credit/currency — delete and re-record the transaction for those.",
30
+ input_schema: {
31
+ type: "object",
32
+ properties: {
33
+ posting_id: { type: "string" },
34
+ account_id: { type: "string" },
35
+ memo: { type: "string" },
36
+ },
37
+ required: ["posting_id"],
38
+ },
39
+ },
40
+ {
41
+ name: "delete_transaction",
42
+ description: "Delete a transaction and (via cascade) all its postings. The primitive for removing duplicates.",
43
+ input_schema: {
44
+ type: "object",
45
+ properties: { transaction_id: { type: "string" } },
46
+ required: ["transaction_id"],
47
+ },
48
+ },
49
+ {
50
+ name: "record_recurrence",
51
+ description: "Create a recurrences row and link every supplied transaction to it. Computes first_seen_date, last_seen_date, and next_expected_date from the member transactions. Use this after the user confirms a recurrence_candidate unknown.",
52
+ input_schema: {
53
+ type: "object",
54
+ properties: {
55
+ account_id: {
56
+ type: "string",
57
+ description: "The account this recurs on.",
58
+ },
59
+ description: {
60
+ type: "string",
61
+ description: "Human label, e.g. 'Spotify subscription', 'Salary', 'Rent'.",
62
+ },
63
+ frequency: {
64
+ type: "string",
65
+ enum: ["weekly", "biweekly", "monthly", "annually"],
66
+ },
67
+ amount_typical: {
68
+ type: "number",
69
+ description: "Representative amount (typically the matching amount of the member transactions).",
70
+ },
71
+ currency: { type: "string", default: "THB" },
72
+ transaction_ids: {
73
+ type: "array",
74
+ items: { type: "string" },
75
+ description: "Transaction ids to link to this recurrence.",
76
+ },
77
+ notes: {
78
+ type: "string",
79
+ description: "Optional context the chat agent can read later.",
80
+ },
81
+ },
82
+ required: ["account_id", "description", "frequency", "transaction_ids"],
83
+ },
84
+ },
85
+ {
86
+ name: "link_transaction_to_recurrence",
87
+ description: "Attach a single newly-seen transaction to an existing recurrence. Recomputes last_seen_date and next_expected_date on the recurrence.",
88
+ input_schema: {
89
+ type: "object",
90
+ properties: {
91
+ transaction_id: { type: "string" },
92
+ recurrence_id: { type: "string" },
93
+ },
94
+ required: ["transaction_id", "recurrence_id"],
95
+ },
96
+ },
97
+ {
98
+ name: "merge_accounts",
99
+ description: "Move every posting on `from_id` over to `to_id`, then delete the source account. Use to apply a similar_accounts unknown's 'Merge A into B' resolution. Refuses if the source still has child accounts.",
100
+ input_schema: {
101
+ type: "object",
102
+ properties: { from_id: { type: "string" }, to_id: { type: "string" } },
103
+ required: ["from_id", "to_id"],
104
+ },
105
+ },
106
+ {
107
+ name: "mark_resolve_done",
108
+ description: "Call once the current unknown's resolution has been applied. The summary is shown to the user. The pipeline will then mark the unknown resolved and move to the next one.",
109
+ input_schema: {
110
+ type: "object",
111
+ properties: { summary: { type: "string" } },
112
+ required: ["summary"],
113
+ },
114
+ },
115
+ ];
116
+ const LABELS = {
117
+ update_transaction: "Updating transaction",
118
+ update_posting: "Updating posting",
119
+ delete_transaction: "Deleting transaction",
120
+ record_recurrence: "Recording recurrence",
121
+ link_transaction_to_recurrence: "Linking transaction to recurrence",
122
+ merge_accounts: "Merging accounts",
123
+ mark_resolve_done: "Finalizing unknown",
124
+ };
125
+ async function execute(db, name, input, ctx) {
126
+ switch (name) {
127
+ case "update_transaction": {
128
+ const changed = updateTransaction(db, input.transaction_id, {
129
+ date: input.date,
130
+ description: input.description,
131
+ source_page: input.source_page,
132
+ });
133
+ return changed === 0
134
+ ? `Transaction ${input.transaction_id} not found or no fields to update.`
135
+ : `Updated transaction ${input.transaction_id}.`;
136
+ }
137
+ case "update_posting": {
138
+ const changed = updatePosting(db, input.posting_id, {
139
+ account_id: input.account_id,
140
+ memo: input.memo,
141
+ });
142
+ return changed === 0
143
+ ? `Posting ${input.posting_id} not found or no fields to update.`
144
+ : `Updated posting ${input.posting_id}.`;
145
+ }
146
+ case "delete_transaction": {
147
+ const changed = deleteTransaction(db, input.transaction_id);
148
+ return changed === 0
149
+ ? `Transaction ${input.transaction_id} not found.`
150
+ : `Deleted transaction ${input.transaction_id} and its postings.`;
151
+ }
152
+ case "record_recurrence": {
153
+ try {
154
+ const id = recordRecurrence(db, {
155
+ account_id: input.account_id,
156
+ description: input.description,
157
+ frequency: input.frequency,
158
+ amount_typical: input.amount_typical ?? null,
159
+ currency: input.currency,
160
+ transaction_ids: input.transaction_ids || [],
161
+ notes: input.notes ?? null,
162
+ });
163
+ return `Recorded recurrence ${id} ("${sanitizeForPrompt(input.description)}", ${input.frequency}); linked ${(input.transaction_ids || []).length} transaction(s).`;
164
+ }
165
+ catch (err) {
166
+ return `Could not record recurrence: ${err.message}`;
167
+ }
168
+ }
169
+ case "link_transaction_to_recurrence": {
170
+ try {
171
+ linkTransactionToRecurrence(db, input.transaction_id, input.recurrence_id);
172
+ return `Linked transaction ${input.transaction_id} → recurrence ${input.recurrence_id}.`;
173
+ }
174
+ catch (err) {
175
+ return `Could not link: ${err.message}`;
176
+ }
177
+ }
178
+ case "merge_accounts": {
179
+ try {
180
+ const moved = mergeAccounts(db, input.from_id, input.to_id);
181
+ return `Merged ${input.from_id} → ${input.to_id}; moved ${moved} posting(s).`;
182
+ }
183
+ catch (err) {
184
+ return `Could not merge: ${err.message}`;
185
+ }
186
+ }
187
+ case "mark_resolve_done": {
188
+ ctx?.onComplete?.(input.summary || "");
189
+ return `Unknown done. Summary: ${sanitizeForPrompt(input.summary || "")}`;
190
+ }
191
+ default:
192
+ return undefined;
193
+ }
194
+ }
195
+ export const resolveTools = { DEFS, LABELS, execute };
@@ -1,9 +1,9 @@
1
1
  import type Database from "libsql";
2
2
  import type { ToolDefinition } from "../provider.js";
3
3
  import type { BufferedWriteContext } from "../../scanner/buffer.js";
4
- export type ToolProfile = "scan" | "chat" | "review" | "record";
4
+ export type ToolProfile = "scan" | "chat" | "resolve" | "record";
5
5
  /**
6
- * Structured highlights the review agent can pass to ask_user. The prompter
6
+ * Structured highlights the resolve agent can pass to ask_user. The prompter
7
7
  * renders them as a single colored header line above the question (each
8
8
  * category gets its own chalk color), so the user can scan amount / date /
9
9
  * merchant / accounts at a glance without parsing prose.
@@ -19,23 +19,21 @@ export interface AgentExecutionContext {
19
19
  fileId?: string;
20
20
  /** When false, ask_user returns a marker and the caller halts after the run. */
21
21
  interactive: boolean;
22
- /** When true, mutating tools become no-ops that return a "would do X" preview. */
23
- dryRun?: boolean;
24
22
  /** Synchronously prompt the user (only invoked when interactive === true). */
25
23
  promptUser?: (prompt: string, options?: string[], facts?: PromptUserFacts) => Promise<string>;
26
- /** Called when the model declares the session is done (scan or review). */
24
+ /** Called when the model declares the session is done (scan or resolve). */
27
25
  onComplete?: (summary: string) => void;
28
26
  /**
29
27
  * Which top-level command this agent serves. Mutating tools branch on this
30
28
  * to decide whether to append an action_log row (currently only "record").
31
29
  */
32
- command?: "scan" | "review" | "record";
30
+ command?: "scan" | "resolve" | "record";
33
31
  /** Per-invocation id grouping every action_log row from one CLI run. */
34
32
  correlationId?: string;
35
33
  /** The raw user utterance / file path that started this invocation. */
36
34
  userInput?: string;
37
35
  /**
38
- * Scan-only: when set, transactions and concerns are queued here instead of
36
+ * Scan-only: when set, transactions and unknowns are queued here instead of
39
37
  * being written directly to the DB. Account and merchant writes still hit
40
38
  * the DB eagerly (serialized via their own mutexes) so concurrent scan
41
39
  * agents share the same chart of accounts and merchant directory.
@@ -1,6 +1,6 @@
1
1
  import chalk from "chalk";
2
2
  import { getDb } from "../../db/connection.js";
3
- import { getAccountBalances } from "../../db/queries/account_balance.js";
3
+ import { getAccountBalances } from "../../db/queries/account-balance.js";
4
4
  import { visibleLength } from "../format.js";
5
5
  import { formatSignedAmount } from "../../currency.js";
6
6
  const TYPE_TAG = {
@@ -42,7 +42,7 @@ export function showAccounts() {
42
42
  console.log(chalk.yellow("No accounts yet. Drop your bank/credit card statements into ~/.plasalid/data/ and run `plasalid scan`."));
43
43
  return;
44
44
  }
45
- const byId = new Map(raw.map(a => [a.id, a]));
45
+ const byId = new Map(raw.map((a) => [a.id, a]));
46
46
  const depthCache = new Map();
47
47
  const depthOf = (id) => {
48
48
  if (depthCache.has(id))
@@ -3,7 +3,7 @@ import { randomUUID } from "crypto";
3
3
  import { getDb } from "../../db/connection.js";
4
4
  import { runRecordAgent } from "../../ai/agent.js";
5
5
  import { makePromptUser, makeAgentOnProgress, statusSpinner } from "../ux.js";
6
- import { listActions } from "../../db/queries/action_log.js";
6
+ import { listActions } from "../../db/queries/action-log.js";
7
7
  import { formatAmount } from "../../currency.js";
8
8
  export async function runRecordCommand(opts) {
9
9
  const utterance = opts.utterance.trim();
@@ -73,7 +73,9 @@ function describeAction(a) {
73
73
  const date = payload?.transaction?.date ?? "";
74
74
  const desc = payload?.transaction?.description ?? "";
75
75
  const total = totalDebit(payload?.postings);
76
- const amount = total != null ? ` ${formatTotal(total, currencyOf(payload?.postings))}` : "";
76
+ const amount = total != null
77
+ ? ` ${formatTotal(total, currencyOf(payload?.postings))}`
78
+ : "";
77
79
  return `record_transaction ${a.target_id} — ${[date, desc].filter(Boolean).join(" ")}${amount}`;
78
80
  }
79
81
  case "adjust_balance": {
@@ -0,0 +1,2 @@
1
+ import { type ResolveOptions } from "../../resolver/pipeline.js";
2
+ export declare function runResolveCommand(opts: ResolveOptions): Promise<void>;
@@ -0,0 +1,13 @@
1
+ import chalk from "chalk";
2
+ import { runResolve } from "../../resolver/pipeline.js";
3
+ export async function runResolveCommand(opts) {
4
+ try {
5
+ const summary = await runResolve(opts);
6
+ console.log("");
7
+ console.log(chalk.bold(summary));
8
+ }
9
+ catch (err) {
10
+ console.error(chalk.red(`Resolve failed: ${err.message}`));
11
+ process.exitCode = 1;
12
+ }
13
+ }