plasalid 0.3.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +33 -43
  2. package/dist/accounts/taxonomy.d.ts +1 -1
  3. package/dist/accounts/taxonomy.js +2 -2
  4. package/dist/ai/agent.d.ts +19 -5
  5. package/dist/ai/agent.js +26 -6
  6. package/dist/ai/memory.d.ts +14 -5
  7. package/dist/ai/memory.js +12 -0
  8. package/dist/ai/personas.d.ts +11 -0
  9. package/dist/ai/personas.js +193 -0
  10. package/dist/ai/prompt-sections.d.ts +49 -0
  11. package/dist/ai/prompt-sections.js +107 -0
  12. package/dist/ai/system-prompt.d.ts +14 -3
  13. package/dist/ai/system-prompt.js +59 -165
  14. package/dist/ai/thinking.js +1 -1
  15. package/dist/ai/tools/common.js +2 -5
  16. package/dist/ai/tools/index.js +32 -7
  17. package/dist/ai/tools/ingest.d.ts +3 -1
  18. package/dist/ai/tools/ingest.js +372 -124
  19. package/dist/ai/tools/merchants.d.ts +2 -0
  20. package/dist/ai/tools/merchants.js +117 -0
  21. package/dist/ai/tools/read.js +57 -24
  22. package/dist/ai/tools/record.d.ts +2 -0
  23. package/dist/ai/tools/record.js +188 -0
  24. package/dist/ai/tools/review.d.ts +2 -0
  25. package/dist/ai/tools/review.js +359 -0
  26. package/dist/ai/tools/scan.js +5 -3
  27. package/dist/ai/tools/types.d.ts +33 -4
  28. package/dist/cli/commands/accounts.js +33 -25
  29. package/dist/cli/commands/record.d.ts +4 -0
  30. package/dist/cli/commands/record.js +119 -0
  31. package/dist/cli/commands/revert.js +1 -1
  32. package/dist/cli/commands/review.d.ts +2 -0
  33. package/dist/cli/commands/review.js +15 -0
  34. package/dist/cli/commands/scan.d.ts +4 -2
  35. package/dist/cli/commands/scan.js +143 -19
  36. package/dist/cli/commands/status.js +6 -9
  37. package/dist/cli/commands/transactions.js +36 -41
  38. package/dist/cli/format.d.ts +2 -0
  39. package/dist/cli/format.js +7 -2
  40. package/dist/cli/index.js +28 -13
  41. package/dist/cli/ink/scan_dashboard.d.ts +38 -0
  42. package/dist/cli/ink/scan_dashboard.js +62 -0
  43. package/dist/cli/setup.d.ts +0 -1
  44. package/dist/cli/setup.js +2 -8
  45. package/dist/cli/ux.d.ts +2 -1
  46. package/dist/cli/ux.js +36 -2
  47. package/dist/currency.d.ts +3 -0
  48. package/dist/currency.js +12 -1
  49. package/dist/db/queries/account_balance.d.ts +84 -4
  50. package/dist/db/queries/account_balance.js +239 -20
  51. package/dist/db/queries/action_log.d.ts +29 -0
  52. package/dist/db/queries/action_log.js +27 -0
  53. package/dist/db/queries/concerns.d.ts +50 -0
  54. package/dist/db/queries/concerns.js +91 -0
  55. package/dist/db/queries/journal.d.ts +75 -8
  56. package/dist/db/queries/journal.js +131 -19
  57. package/dist/db/queries/merchants.d.ts +42 -0
  58. package/dist/db/queries/merchants.js +120 -0
  59. package/dist/db/queries/recurrences.d.ts +33 -0
  60. package/dist/db/queries/recurrences.js +128 -0
  61. package/dist/db/queries/search.d.ts +5 -4
  62. package/dist/db/queries/search.js +16 -12
  63. package/dist/db/queries/transactions.d.ts +167 -0
  64. package/dist/db/queries/transactions.js +320 -0
  65. package/dist/db/schema.js +74 -9
  66. package/dist/reviewer/pipeline.d.ts +18 -0
  67. package/dist/reviewer/pipeline.js +46 -0
  68. package/dist/reviewer/prompts.d.ts +12 -0
  69. package/dist/reviewer/prompts.js +22 -0
  70. package/dist/scanner/account_mutex.d.ts +1 -0
  71. package/dist/scanner/account_mutex.js +16 -0
  72. package/dist/scanner/buffer.d.ts +51 -0
  73. package/dist/scanner/buffer.js +63 -0
  74. package/dist/scanner/concurrency.d.ts +14 -0
  75. package/dist/scanner/concurrency.js +31 -0
  76. package/dist/scanner/decrypt_queue.d.ts +57 -0
  77. package/dist/scanner/decrypt_queue.js +96 -0
  78. package/dist/scanner/pipeline.d.ts +47 -18
  79. package/dist/scanner/pipeline.js +247 -97
  80. package/dist/scanner/prompts.js +3 -3
  81. package/package.json +2 -2
@@ -1,20 +1,35 @@
1
- import { randomUUID } from "crypto";
2
- import { findAccountById } from "../../db/queries/account_balance.js";
3
- import { recordJournalEntry } from "../../db/queries/journal.js";
1
+ import { createAccount, updateAccountMetadata, findAccountById, } from "../../db/queries/account_balance.js";
2
+ import { validateTransaction, insertTransactionRows, recordTransaction, } from "../../db/queries/transactions.js";
3
+ import { appendAction } from "../../db/queries/action_log.js";
4
+ import { getConcernTarget, recordConcern, resolveConcern, } from "../../db/queries/concerns.js";
5
+ import { runExclusive as runAccountExclusive } from "../../scanner/account_mutex.js";
4
6
  import { sanitizeForPrompt } from "../sanitize.js";
5
- import { ALL_THAI_INSTITUTIONS, ACCOUNT_TYPE_DESCRIPTIONS, } from "../../accounts/taxonomy.js";
7
+ import { ACCOUNT_TYPE_DESCRIPTIONS } from "../../accounts/taxonomy.js";
6
8
  const ACCOUNT_TYPES = Object.keys(ACCOUNT_TYPE_DESCRIPTIONS);
7
- const DEFS = [
9
+ /**
10
+ * Account + transaction write primitives
11
+ *
12
+ * Shared by scan, review, and record. Each tool branches once on
13
+ * `ctx.correlationId`: when set (record path), the data write and the
14
+ * action_log insert run inside a single transaction so the audit row is
15
+ * atomic with the change. Without it (scan / review), the write goes through
16
+ * the existing path unchanged.
17
+ */
18
+ const ACCOUNT_DEFS = [
8
19
  {
9
20
  name: "create_account",
10
- description: "Create a new account in the chart of accounts when a statement reveals one that doesn't exist yet. Use a stable id like 'asset:kbank-savings-1234'.",
21
+ description: "Create a new account in the chart of accounts. Account ids are colon-paths under one of the five top-level type roots ('asset', 'liability', 'income', 'expense', 'equity'). Examples: 'asset:kbank-savings-1234', 'expense:food', 'expense:food:groceries'. Every non-root account must have a parent_id that already exists and shares its type; create intermediate parents (e.g. 'expense:food') before their leaves. Top-level roots are auto-bootstrapped on first use.",
11
22
  input_schema: {
12
23
  type: "object",
13
24
  properties: {
14
- id: { type: "string", description: "Stable identifier, lowercase, colon-separated. e.g. 'asset:kbank-savings-1234'." },
15
- name: { type: "string", description: "Human-readable name. e.g. 'KBank Savings ••1234'." },
16
- type: { type: "string", enum: ACCOUNT_TYPES, description: "Account type." },
17
- subtype: { type: "string", description: "e.g. 'bank', 'credit_card', 'salary', 'food'." },
25
+ id: { type: "string", description: "Stable colon-path identifier, lowercase. e.g. 'expense:food:groceries' or 'asset:kbank-savings-1234'." },
26
+ name: { type: "string", description: "Human-readable name. e.g. 'Groceries' or 'KBank Savings ••1234'." },
27
+ type: { type: "string", enum: ACCOUNT_TYPES, description: "Account type. Must match the parent's type." },
28
+ parent_id: {
29
+ type: ["string", "null"],
30
+ description: "Parent account id (the prefix before the final ':' segment). Pass null only when creating one of the five top-level type roots — and then id must equal type. Examples: id='expense:food:groceries' → parent_id='expense:food'. id='expense:food' → parent_id='expense'. id='expense' → parent_id=null.",
31
+ },
32
+ subtype: { type: "string", description: "e.g. 'bank', 'credit_card', 'salary'." },
18
33
  bank_name: { type: "string", description: "Thai institution code from the taxonomy (e.g. KBANK, SCB, KTC)." },
19
34
  account_number_masked: { type: "string", description: "Last 4 digits only, e.g. '••1234'." },
20
35
  currency: { type: "string", description: "ISO 4217 code. Defaults to 'THB'.", default: "THB" },
@@ -22,12 +37,12 @@ const DEFS = [
22
37
  statement_day: { type: "number", description: "Statement-cut day of month." },
23
38
  metadata: { type: "object", description: "Free-form extra fields (e.g. {points_program: 'KTC Forever'})." },
24
39
  },
25
- required: ["id", "name", "type"],
40
+ required: ["id", "name", "type", "parent_id"],
26
41
  },
27
42
  },
28
43
  {
29
44
  name: "update_account_metadata",
30
- description: "Update account metadata (due day, statement day, points balance, masked number, bank).",
45
+ description: "Update account metadata (due day, statement day, points balance, masked number, bank). Use this — not record_transaction — when the user says things like 'set my KTC due day to 20' or 'statement day 28'.",
31
46
  input_schema: {
32
47
  type: "object",
33
48
  properties: {
@@ -43,57 +58,58 @@ const DEFS = [
43
58
  },
44
59
  },
45
60
  {
46
- name: "record_journal_entry",
47
- description: "Post a balanced double-entry journal entry. The sum of debits MUST equal the sum of credits (within one currency). Convert Buddhist-Era dates by subtracting 543. Every line carries an ISO 4217 currency code (THB, USD, EUR, …); default to THB. Use the account's currency where set; only deviate when the source row is explicitly in another currency.",
61
+ name: "record_transaction",
62
+ description: "Post one balanced double-entry transaction — the right tool for any real-world event (purchase, payment, transfer, refund, salary, withdrawal). Use adjust_account_balance instead when the user is stating a current balance rather than describing a transaction. The sum of debits MUST equal the sum of credits (within one currency). Convert Buddhist-Era dates by subtracting 543. Each posting carries an ISO 4217 currency code (THB, USD, EUR, …); default to THB. Use the account's currency where set; only deviate when the source row is explicitly in another currency. When the transaction has an external counter-party, attach a `merchant` block — Plasalid dedups merchants and learns a default expense account per merchant so future statements skip re-categorization.",
48
63
  input_schema: {
49
64
  type: "object",
50
65
  properties: {
51
66
  date: { type: "string", description: "ISO Gregorian date (YYYY-MM-DD)." },
52
- description: { type: "string", description: "Short human-readable description of the entry." },
67
+ description: { type: "string", description: "Short human-readable description." },
53
68
  source_page: { type: "number", description: "Page number in the source PDF, if known." },
54
- lines: {
69
+ raw_descriptor: {
70
+ type: "string",
71
+ description: "The exact statement line (the raw merchant descriptor) when posting from a PDF — preserved for alias matching and later review. Omit for manual entries and transfers.",
72
+ },
73
+ merchant: {
74
+ type: "object",
75
+ description: "Counter-party block. Omit for transfers between own accounts and pure metadata movements. When set during a scan, Plasalid upserts the merchant by canonical_name and (optionally) records the raw descriptor as an alias for future matches. Set default_account_id to teach the cache when categorization is confident.",
76
+ properties: {
77
+ canonical_name: { type: "string", description: "Normalized merchant name, Title Case. e.g. 'Starbucks', 'Amazon', 'Spotify'." },
78
+ alias: { type: "string", description: "The raw descriptor exactly as it appears on the statement. Plasalid normalizes and stores it so future statements skip the LLM." },
79
+ default_account_id: { type: "string", description: "Optional learned cache: 'this merchant's expense category is X'. Set when categorization is confident." },
80
+ },
81
+ required: ["canonical_name"],
82
+ },
83
+ merchant_id: {
84
+ type: "string",
85
+ description: "Pre-resolved merchant id (from the scanner's alias pre-pass). When set, the merchant block is ignored. The scanner uses this to skip re-categorizing merchants it already knows.",
86
+ },
87
+ postings: {
55
88
  type: "array",
56
- description: "Two or more journal lines that balance.",
89
+ description: "Two or more postings that balance.",
57
90
  items: {
58
91
  type: "object",
59
92
  properties: {
60
93
  account_id: { type: "string", description: "Existing account id from list_accounts or create_account." },
61
- debit: { type: "number", description: "Debit amount in this line's currency. Use 0 if this line is a credit." },
62
- credit: { type: "number", description: "Credit amount in this line's currency. Use 0 if this line is a debit." },
63
- currency: { type: "string", description: "ISO 4217 currency code for this line (e.g. THB, USD, EUR). Defaults to THB.", default: "THB" },
64
- memo: { type: "string", description: "Optional per-line memo." },
94
+ debit: { type: "number", description: "Debit amount in this posting's currency. Use 0 if this posting is a credit." },
95
+ credit: { type: "number", description: "Credit amount in this posting's currency. Use 0 if this posting is a debit." },
96
+ currency: { type: "string", description: "ISO 4217 currency code for this posting (e.g. THB, USD, EUR). Defaults to THB.", default: "THB" },
97
+ memo: { type: "string", description: "Optional per-posting memo." },
65
98
  },
66
99
  required: ["account_id"],
67
100
  },
68
101
  },
69
102
  },
70
- required: ["date", "description", "lines"],
71
- },
72
- },
73
- {
74
- name: "ask_user",
75
- description: "Ask the user a clarifying question when you cannot confidently proceed. The pipeline pauses and prompts the user interactively when running `plasalid scan` or `plasalid reconcile`.",
76
- input_schema: {
77
- type: "object",
78
- properties: {
79
- prompt: { type: "string", description: "The question to ask in plain language." },
80
- options: {
81
- type: "array",
82
- description: "Optional list of candidate answers.",
83
- items: { type: "string" },
84
- },
85
- },
86
- required: ["prompt"],
103
+ required: ["date", "description", "postings"],
87
104
  },
88
105
  },
89
106
  ];
90
- const LABELS = {
107
+ const ACCOUNT_LABELS = {
91
108
  create_account: "Creating account",
92
109
  update_account_metadata: "Updating account metadata",
93
- record_journal_entry: "Posting journal entry",
94
- ask_user: "Asking for clarification",
110
+ record_transaction: "Posting transaction",
95
111
  };
96
- async function execute(db, name, input, ctx) {
112
+ async function accountExecute(db, name, input, ctx) {
97
113
  switch (name) {
98
114
  case "create_account": {
99
115
  if (ctx?.dryRun)
@@ -101,102 +117,334 @@ async function execute(db, name, input, ctx) {
101
117
  if (!ACCOUNT_TYPES.includes(input.type)) {
102
118
  return `Invalid type "${input.type}". Allowed: ${ACCOUNT_TYPES.join(", ")}.`;
103
119
  }
104
- const knownCodes = new Set(ALL_THAI_INSTITUTIONS.map(i => i.code));
105
- const bank = input.bank_name ? String(input.bank_name).toUpperCase() : null;
106
- if (bank && !knownCodes.has(bank)) {
107
- // Allow unknown institutions; taxonomy is a hint, not a hard list.
108
- }
109
- try {
110
- db.prepare(`INSERT INTO accounts (id, name, type, subtype, bank_name, account_number_masked, currency, due_day, statement_day, metadata_json)
111
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.id, input.name, input.type, input.subtype || null, bank, input.account_number_masked || null, input.currency || "THB", input.due_day ?? null, input.statement_day ?? null, input.metadata ? JSON.stringify(input.metadata) : null);
112
- return `Account created: ${input.id} (${input.name}, ${input.type}).`;
113
- }
114
- catch (err) {
115
- if (String(err.message).includes("UNIQUE")) {
116
- return `Account "${input.id}" already exists. Use update_account_metadata to modify it.`;
120
+ return await runAccountExclusive(() => {
121
+ try {
122
+ const create = () => {
123
+ createAccount(db, {
124
+ id: input.id,
125
+ name: input.name,
126
+ type: input.type,
127
+ parent_id: input.parent_id ?? null,
128
+ subtype: input.subtype ?? null,
129
+ bank_name: input.bank_name ?? null,
130
+ account_number_masked: input.account_number_masked ?? null,
131
+ currency: input.currency,
132
+ due_day: input.due_day ?? null,
133
+ statement_day: input.statement_day ?? null,
134
+ metadata: input.metadata ?? null,
135
+ });
136
+ };
137
+ if (ctx?.correlationId) {
138
+ const tx = db.transaction(() => {
139
+ create();
140
+ const row = findAccountById(db, input.id);
141
+ appendAction(db, {
142
+ correlation_id: ctx.correlationId,
143
+ command: ctx.command ?? "record",
144
+ user_input: ctx.userInput ?? null,
145
+ action_type: "create_account",
146
+ target_id: input.id,
147
+ payload: { row },
148
+ });
149
+ });
150
+ tx();
151
+ }
152
+ else {
153
+ create();
154
+ }
155
+ return `Account created: ${input.id} (${input.name}, ${input.type}).`;
117
156
  }
118
- throw err;
119
- }
157
+ catch (err) {
158
+ if (err.code === "ACCOUNT_EXISTS") {
159
+ return `Account "${input.id}" already exists. Use update_account_metadata to modify it.`;
160
+ }
161
+ return `Could not create account "${input.id}": ${err.message}`;
162
+ }
163
+ });
120
164
  }
121
165
  case "update_account_metadata": {
122
166
  if (ctx?.dryRun)
123
167
  return `Would update metadata for ${input.account_id}: ${JSON.stringify(input)}`;
124
- const acct = findAccountById(db, input.account_id);
125
- if (!acct)
126
- return `Account "${input.account_id}" not found.`;
127
- const updates = [];
128
- const params = [];
129
- if (input.due_day !== undefined) {
130
- updates.push("due_day = ?");
131
- params.push(input.due_day);
132
- }
133
- if (input.statement_day !== undefined) {
134
- updates.push("statement_day = ?");
135
- params.push(input.statement_day);
136
- }
137
- if (input.points_balance !== undefined) {
138
- updates.push("points_balance = ?");
139
- params.push(input.points_balance);
140
- }
141
- if (input.account_number_masked !== undefined) {
142
- updates.push("account_number_masked = ?");
143
- params.push(input.account_number_masked);
144
- }
145
- if (input.bank_name !== undefined) {
146
- updates.push("bank_name = ?");
147
- params.push(String(input.bank_name).toUpperCase());
148
- }
149
- if (input.metadata) {
150
- const existing = acct.metadata_json ? JSON.parse(acct.metadata_json) : {};
151
- const merged = { ...existing, ...input.metadata };
152
- updates.push("metadata_json = ?");
153
- params.push(JSON.stringify(merged));
154
- }
155
- if (updates.length === 0)
156
- return "Nothing to update.";
157
- params.push(input.account_id);
158
- db.prepare(`UPDATE accounts SET ${updates.join(", ")} WHERE id = ?`).run(...params);
159
- return `Updated ${input.account_id}.`;
168
+ return await runAccountExclusive(() => {
169
+ try {
170
+ let changed = false;
171
+ const apply = () => {
172
+ const result = updateAccountMetadata(db, input.account_id, {
173
+ due_day: input.due_day,
174
+ statement_day: input.statement_day,
175
+ points_balance: input.points_balance,
176
+ account_number_masked: input.account_number_masked,
177
+ bank_name: input.bank_name,
178
+ metadata: input.metadata,
179
+ });
180
+ changed = result.changed;
181
+ return result;
182
+ };
183
+ if (ctx?.correlationId) {
184
+ const tx = db.transaction(() => {
185
+ const result = apply();
186
+ if (!result.changed)
187
+ return;
188
+ appendAction(db, {
189
+ correlation_id: ctx.correlationId,
190
+ command: ctx.command ?? "record",
191
+ user_input: ctx.userInput ?? null,
192
+ action_type: "update_account_metadata",
193
+ target_id: input.account_id,
194
+ payload: { before: result.before, after: result.after },
195
+ });
196
+ });
197
+ tx();
198
+ }
199
+ else {
200
+ apply();
201
+ }
202
+ return changed ? `Updated ${input.account_id}.` : "Nothing to update.";
203
+ }
204
+ catch (err) {
205
+ if (String(err.message).includes("not found")) {
206
+ return `Account "${input.account_id}" not found.`;
207
+ }
208
+ throw err;
209
+ }
210
+ });
160
211
  }
161
- case "record_journal_entry": {
212
+ case "record_transaction": {
162
213
  if (!ctx)
163
- return "record_journal_entry is only available inside an agent session.";
214
+ return "record_transaction is only available inside an agent session.";
164
215
  if (ctx.dryRun)
165
- return `Would post journal entry "${input.description}" on ${input.date}.`;
216
+ return `Would post transaction "${input.description}" on ${input.date}.`;
217
+ const txInput = {
218
+ date: input.date,
219
+ description: input.description,
220
+ source_file_id: ctx.fileId,
221
+ source_page: input.source_page ?? null,
222
+ raw_descriptor: input.raw_descriptor ?? null,
223
+ merchant: input.merchant ?? null,
224
+ merchant_id: input.merchant_id ?? null,
225
+ postings: (input.postings || []).map((p) => ({
226
+ account_id: p.account_id,
227
+ debit: p.debit ?? 0,
228
+ credit: p.credit ?? 0,
229
+ currency: p.currency || "THB",
230
+ memo: p.memo ?? null,
231
+ })),
232
+ };
166
233
  try {
167
- const entryId = recordJournalEntry(db, {
168
- date: input.date,
169
- description: input.description,
170
- source_file_id: ctx.fileId,
171
- source_page: input.source_page ?? null,
172
- lines: (input.lines || []).map((l) => ({
173
- account_id: l.account_id,
174
- debit: l.debit ?? 0,
175
- credit: l.credit ?? 0,
176
- currency: l.currency || "THB",
177
- memo: l.memo ?? null,
178
- })),
179
- });
180
- return `Posted journal entry ${entryId} (${input.date}).`;
234
+ let transactionId;
235
+ if (ctx.buffer) {
236
+ transactionId = ctx.buffer.appendTransaction(txInput);
237
+ }
238
+ else if (ctx.correlationId) {
239
+ const validated = validateTransaction(txInput);
240
+ const tx = db.transaction(() => {
241
+ insertTransactionRows(db, validated);
242
+ appendAction(db, {
243
+ correlation_id: ctx.correlationId,
244
+ command: ctx.command ?? "record",
245
+ user_input: ctx.userInput ?? null,
246
+ action_type: "record_transaction",
247
+ target_id: validated.id,
248
+ payload: {
249
+ transaction: {
250
+ date: validated.date,
251
+ description: validated.description,
252
+ source_page: validated.source_page ?? null,
253
+ raw_descriptor: validated.raw_descriptor ?? null,
254
+ },
255
+ postings: validated.postings,
256
+ },
257
+ });
258
+ });
259
+ tx();
260
+ transactionId = validated.id;
261
+ }
262
+ else {
263
+ transactionId = recordTransaction(db, txInput);
264
+ }
265
+ return `Posted transaction ${transactionId} (${input.date}).`;
181
266
  }
182
267
  catch (err) {
183
- return `Could not post journal entry: ${err.message}`;
184
- }
185
- }
186
- case "ask_user": {
187
- if (!ctx)
188
- return "ask_user is only available inside an agent session.";
189
- const id = `pq:${randomUUID()}`;
190
- db.prepare(`INSERT INTO pending_questions (id, file_id, prompt, options_json) VALUES (?, ?, ?, ?)`).run(id, ctx.fileId ?? null, input.prompt, input.options ? JSON.stringify(input.options) : null);
191
- if (ctx.interactive && ctx.promptUser) {
192
- const answer = await ctx.promptUser(input.prompt, input.options);
193
- db.prepare(`UPDATE pending_questions SET answer = ?, resolved_at = datetime('now') WHERE id = ?`).run(answer, id);
194
- return `User answered: ${sanitizeForPrompt(answer)}`;
268
+ return `Could not post transaction: ${err.message}`;
195
269
  }
196
- return `Question recorded for later (${id}). Awaiting user input — do not act on assumptions about this answer.`;
197
270
  }
198
271
  default:
199
272
  return undefined;
200
273
  }
201
274
  }
202
- export const ingestTools = { DEFS, LABELS, execute };
275
+ export const accountIngestTools = {
276
+ DEFS: ACCOUNT_DEFS,
277
+ LABELS: ACCOUNT_LABELS,
278
+ execute: accountExecute,
279
+ };
280
+ /**
281
+ * Scan-only concerns
282
+ *
283
+ * `note_concern` records a clarification mid-scan without ever prompting the
284
+ * user — only scan needs this. Record uses `clarify` (transient prompt, no
285
+ * concerns-table residue); review uses `ask_user` (prompts and resolves).
286
+ */
287
+ const CONCERN_DEFS = [
288
+ {
289
+ name: "note_concern",
290
+ description: "Record a clarification request without pausing the run. Use during scan when a row is ambiguous (post your best-guess transaction first, then call this with the transaction's id), when a row is unparseable (skip the transaction, call this with no transaction_id), or when you have a concern about an account itself (pass account_id). Use kind='uncategorized_expense' when posting an expense to expense:uncategorized so reviewer can group these. The reviewer picks these up later with the full picture.",
291
+ input_schema: {
292
+ type: "object",
293
+ properties: {
294
+ prompt: {
295
+ type: "string",
296
+ description: "The question or concern in a complete sentence, with date, ฿-formatted amount, and human account names. Never reference internal ids.",
297
+ },
298
+ kind: {
299
+ type: "string",
300
+ description: "Optional category for the concern. Use 'uncategorized_expense' when the posting landed in expense:uncategorized; reviewer batches these into one cleanup pass.",
301
+ },
302
+ options: {
303
+ type: "array",
304
+ description: "Optional list of candidate answers the reviewer can offer the user.",
305
+ items: { type: "string" },
306
+ },
307
+ transaction_id: {
308
+ type: "string",
309
+ description: "Id of the transaction this concern relates to (returned by record_transaction). Omit for file-level concerns about an unparseable row.",
310
+ },
311
+ account_id: {
312
+ type: "string",
313
+ description: "Id of the account this concern relates to. Set when the statement's bank name, currency, statement_day, due_day, or other metadata disagrees with the stored account, or when you suspect a new account you're about to create duplicates an existing one. Can be combined with transaction_id.",
314
+ },
315
+ },
316
+ required: ["prompt"],
317
+ },
318
+ },
319
+ ];
320
+ const CONCERN_LABELS = {
321
+ note_concern: "Noting concern",
322
+ };
323
+ async function concernExecute(db, name, input, ctx) {
324
+ if (name !== "note_concern")
325
+ return undefined;
326
+ if (!ctx)
327
+ return "note_concern is only available inside an agent session.";
328
+ const target = {
329
+ transaction_id: input.transaction_id ?? null,
330
+ account_id: input.account_id ?? null,
331
+ };
332
+ if (ctx.buffer) {
333
+ ctx.buffer.appendConcern({ ...target, kind: input.kind ?? null, prompt: input.prompt, options: input.options });
334
+ return `Concern noted (buffered). Continue with the next row.`;
335
+ }
336
+ const id = recordConcern(db, {
337
+ file_id: ctx.fileId ?? null,
338
+ transaction_id: target.transaction_id,
339
+ account_id: target.account_id,
340
+ kind: input.kind ?? null,
341
+ prompt: input.prompt,
342
+ options: input.options,
343
+ });
344
+ return `Concern noted (${id}). Continue with the next row.`;
345
+ }
346
+ export const scanConcernTools = {
347
+ DEFS: CONCERN_DEFS,
348
+ LABELS: CONCERN_LABELS,
349
+ execute: concernExecute,
350
+ };
351
+ /**
352
+ * Review-only tool definitions
353
+ *
354
+ * `ask_user` is the only interactive primitive. Scan never reaches it (the
355
+ * scan profile doesn't include this module), so we don't need a "scan, please
356
+ * don't use this" guard.
357
+ */
358
+ const REVIEW_DEFS = [
359
+ {
360
+ name: "ask_user",
361
+ description: "Ask the user a clarifying question when you cannot confidently proceed. The pipeline pauses and prompts the user interactively. Available during `plasalid review`. Not exposed during `plasalid scan` — use `note_concern` instead. Pass `transaction_id` / `account_id` to attach the question to the same target as a scan-noted concern. Pass `concern_id` to resolve an existing open concern in place (recommended when re-posing a scan-noted concern to the user). Pass `related_concern_ids` to apply the user's single answer to a whole group of sibling concerns at once.",
362
+ input_schema: {
363
+ type: "object",
364
+ properties: {
365
+ prompt: { type: "string", description: "The question to ask in plain language." },
366
+ options: {
367
+ type: "array",
368
+ description: "Optional list of candidate answers.",
369
+ items: { type: "string" },
370
+ },
371
+ transaction_id: {
372
+ type: "string",
373
+ description: "Optional: transaction this question is about. Used to clear the transaction's has_concern flag once all its concerns close.",
374
+ },
375
+ account_id: {
376
+ type: "string",
377
+ description: "Optional: account this question is about. Used to clear the account's has_concern flag once all its concerns close.",
378
+ },
379
+ concern_id: {
380
+ type: "string",
381
+ description: "Optional: id of an existing open concern. If supplied, the user's answer resolves that row in place instead of creating a new one.",
382
+ },
383
+ related_concern_ids: {
384
+ type: "array",
385
+ items: { type: "string" },
386
+ description: "Optional: ids of additional open concerns that share the same answer as `concern_id`. The user is prompted once; every listed concern (plus the primary) is marked resolved with the same answer. Use this for grouping duplicate questions — e.g., 12 Lazada rows that all categorize the same way — so the user isn't asked the same thing twelve times.",
387
+ },
388
+ facts: {
389
+ type: "object",
390
+ description: "Optional structured highlights rendered as a single colored header line above the question. Provide whichever fields apply; the prompter colorizes each by category (amount=yellow, date=cyan, merchant=green, accounts=magenta). Keep the `prompt` text short — the facts header carries the context.",
391
+ properties: {
392
+ amount: { type: "string", description: "฿-formatted amount, e.g. '฿1,200.00'." },
393
+ date: { type: "string", description: "ISO date or short range, e.g. '2026-04-15' or '2026-02-15 to 2026-05-15'." },
394
+ merchant: { type: "string", description: "Counterparty / merchant name, e.g. 'LAZADA TH', 'Spotify'." },
395
+ accounts: {
396
+ type: "array",
397
+ items: { type: "string" },
398
+ description: "Human account names involved. For merges, list the survivor first.",
399
+ },
400
+ },
401
+ },
402
+ },
403
+ required: ["prompt"],
404
+ },
405
+ },
406
+ ];
407
+ const REVIEW_LABELS = {
408
+ ask_user: "Asking for clarification",
409
+ };
410
+ async function reviewExecute(db, name, input, ctx) {
411
+ if (name !== "ask_user")
412
+ return undefined;
413
+ if (!ctx)
414
+ return "ask_user is only available inside an agent session.";
415
+ let id;
416
+ if (input.concern_id) {
417
+ id = String(input.concern_id);
418
+ if (!getConcernTarget(db, id))
419
+ return `Concern ${id} not found.`;
420
+ }
421
+ else {
422
+ id = recordConcern(db, {
423
+ file_id: ctx.fileId ?? null,
424
+ transaction_id: input.transaction_id ?? null,
425
+ account_id: input.account_id ?? null,
426
+ prompt: input.prompt,
427
+ options: input.options,
428
+ });
429
+ }
430
+ if (ctx.interactive && ctx.promptUser) {
431
+ const answer = await ctx.promptUser(input.prompt, input.options, input.facts);
432
+ resolveConcern(db, id, answer);
433
+ const siblings = Array.isArray(input.related_concern_ids) ? input.related_concern_ids : [];
434
+ let propagated = 0;
435
+ for (const sibId of siblings) {
436
+ if (sibId === id)
437
+ continue;
438
+ if (resolveConcern(db, String(sibId), answer))
439
+ propagated++;
440
+ }
441
+ const totalResolved = 1 + propagated;
442
+ return `User answered: ${sanitizeForPrompt(answer)}${totalResolved > 1 ? ` (applied to ${totalResolved} concern${totalResolved === 1 ? "" : "s"})` : ""}`;
443
+ }
444
+ return `Question recorded for later (${id}). Awaiting user input — do not act on assumptions about this answer.`;
445
+ }
446
+ export const reviewIngestTools = {
447
+ DEFS: REVIEW_DEFS,
448
+ LABELS: REVIEW_LABELS,
449
+ execute: reviewExecute,
450
+ };
@@ -0,0 +1,2 @@
1
+ import type { ToolModule } from "./types.js";
2
+ export declare const merchantTools: ToolModule;