plasalid 0.4.1 → 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 (65) hide show
  1. package/README.md +15 -14
  2. package/dist/ai/agent.d.ts +15 -2
  3. package/dist/ai/agent.js +21 -2
  4. package/dist/ai/memory.d.ts +2 -0
  5. package/dist/ai/memory.js +2 -2
  6. package/dist/ai/personas.d.ts +2 -1
  7. package/dist/ai/personas.js +115 -45
  8. package/dist/ai/prompt-sections.d.ts +5 -0
  9. package/dist/ai/prompt-sections.js +26 -8
  10. package/dist/ai/system-prompt.d.ts +11 -0
  11. package/dist/ai/system-prompt.js +21 -6
  12. package/dist/ai/thinking.js +1 -1
  13. package/dist/ai/tools/common.js +2 -5
  14. package/dist/ai/tools/index.js +28 -8
  15. package/dist/ai/tools/ingest.d.ts +2 -1
  16. package/dist/ai/tools/ingest.js +262 -151
  17. package/dist/ai/tools/merchants.d.ts +2 -0
  18. package/dist/ai/tools/merchants.js +117 -0
  19. package/dist/ai/tools/read.js +31 -29
  20. package/dist/ai/tools/record.d.ts +2 -0
  21. package/dist/ai/tools/record.js +188 -0
  22. package/dist/ai/tools/review.js +77 -80
  23. package/dist/ai/tools/scan.js +1 -1
  24. package/dist/ai/tools/types.d.ts +15 -6
  25. package/dist/cli/commands/accounts.js +33 -25
  26. package/dist/cli/commands/record.d.ts +4 -0
  27. package/dist/cli/commands/record.js +119 -0
  28. package/dist/cli/commands/revert.js +1 -1
  29. package/dist/cli/commands/scan.js +15 -19
  30. package/dist/cli/commands/status.js +6 -9
  31. package/dist/cli/commands/transactions.js +36 -41
  32. package/dist/cli/format.d.ts +2 -0
  33. package/dist/cli/format.js +7 -2
  34. package/dist/cli/index.js +19 -7
  35. package/dist/cli/ink/scan_dashboard.d.ts +1 -1
  36. package/dist/cli/ink/scan_dashboard.js +2 -2
  37. package/dist/cli/setup.d.ts +0 -1
  38. package/dist/cli/setup.js +2 -8
  39. package/dist/currency.d.ts +3 -0
  40. package/dist/currency.js +12 -1
  41. package/dist/db/queries/account_balance.d.ts +83 -4
  42. package/dist/db/queries/account_balance.js +239 -20
  43. package/dist/db/queries/action_log.d.ts +29 -0
  44. package/dist/db/queries/action_log.js +27 -0
  45. package/dist/db/queries/concerns.d.ts +10 -7
  46. package/dist/db/queries/concerns.js +20 -16
  47. package/dist/db/queries/journal.d.ts +1 -0
  48. package/dist/db/queries/merchants.d.ts +42 -0
  49. package/dist/db/queries/merchants.js +120 -0
  50. package/dist/db/queries/recurrences.d.ts +3 -3
  51. package/dist/db/queries/recurrences.js +32 -34
  52. package/dist/db/queries/search.d.ts +5 -4
  53. package/dist/db/queries/search.js +16 -12
  54. package/dist/db/queries/transactions.d.ts +167 -0
  55. package/dist/db/queries/transactions.js +320 -0
  56. package/dist/db/schema.js +51 -9
  57. package/dist/reviewer/pipeline.d.ts +4 -4
  58. package/dist/reviewer/pipeline.js +4 -4
  59. package/dist/reviewer/prompts.js +4 -4
  60. package/dist/scanner/buffer.d.ts +24 -21
  61. package/dist/scanner/buffer.js +18 -18
  62. package/dist/scanner/pipeline.d.ts +3 -2
  63. package/dist/scanner/pipeline.js +33 -36
  64. package/dist/scanner/prompts.js +3 -3
  65. package/package.json +2 -2
@@ -1,27 +1,35 @@
1
- import { findAccountById } from "../../db/queries/account_balance.js";
2
- 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";
3
4
  import { getConcernTarget, recordConcern, resolveConcern, } from "../../db/queries/concerns.js";
4
5
  import { runExclusive as runAccountExclusive } from "../../scanner/account_mutex.js";
5
6
  import { sanitizeForPrompt } from "../sanitize.js";
6
- import { ACCOUNT_TYPE_DESCRIPTIONS, } from "../../accounts/taxonomy.js";
7
+ import { ACCOUNT_TYPE_DESCRIPTIONS } from "../../accounts/taxonomy.js";
7
8
  const ACCOUNT_TYPES = Object.keys(ACCOUNT_TYPE_DESCRIPTIONS);
8
- // ── Scan-side tool definitions ──────────────────────────────────────────────
9
- // These tools are exposed during both `plasalid scan` and `plasalid review`:
10
- // scan uses them to post the initial picture; review uses the same primitives
11
- // to fix mistakes (re-create a botched account, post a corrected entry, etc.).
12
- // `note_concern` belongs here too it records a clarification without ever
13
- // prompting the user, which is what scan needs.
14
- const SCAN_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 = [
15
19
  {
16
20
  name: "create_account",
17
- 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.",
18
22
  input_schema: {
19
23
  type: "object",
20
24
  properties: {
21
- id: { type: "string", description: "Stable identifier, lowercase, colon-separated. e.g. 'asset:kbank-savings-1234'." },
22
- name: { type: "string", description: "Human-readable name. e.g. 'KBank Savings ••1234'." },
23
- type: { type: "string", enum: ACCOUNT_TYPES, description: "Account type." },
24
- 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'." },
25
33
  bank_name: { type: "string", description: "Thai institution code from the taxonomy (e.g. KBANK, SCB, KTC)." },
26
34
  account_number_masked: { type: "string", description: "Last 4 digits only, e.g. '••1234'." },
27
35
  currency: { type: "string", description: "ISO 4217 code. Defaults to 'THB'.", default: "THB" },
@@ -29,12 +37,12 @@ const SCAN_DEFS = [
29
37
  statement_day: { type: "number", description: "Statement-cut day of month." },
30
38
  metadata: { type: "object", description: "Free-form extra fields (e.g. {points_program: 'KTC Forever'})." },
31
39
  },
32
- required: ["id", "name", "type"],
40
+ required: ["id", "name", "type", "parent_id"],
33
41
  },
34
42
  },
35
43
  {
36
44
  name: "update_account_metadata",
37
- 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'.",
38
46
  input_schema: {
39
47
  type: "object",
40
48
  properties: {
@@ -50,68 +58,58 @@ const SCAN_DEFS = [
50
58
  },
51
59
  },
52
60
  {
53
- name: "record_journal_entry",
54
- 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.",
55
63
  input_schema: {
56
64
  type: "object",
57
65
  properties: {
58
66
  date: { type: "string", description: "ISO Gregorian date (YYYY-MM-DD)." },
59
- description: { type: "string", description: "Short human-readable description of the entry." },
67
+ description: { type: "string", description: "Short human-readable description." },
60
68
  source_page: { type: "number", description: "Page number in the source PDF, if known." },
61
- 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: {
62
88
  type: "array",
63
- description: "Two or more journal lines that balance.",
89
+ description: "Two or more postings that balance.",
64
90
  items: {
65
91
  type: "object",
66
92
  properties: {
67
93
  account_id: { type: "string", description: "Existing account id from list_accounts or create_account." },
68
- debit: { type: "number", description: "Debit amount in this line's currency. Use 0 if this line is a credit." },
69
- credit: { type: "number", description: "Credit amount in this line's currency. Use 0 if this line is a debit." },
70
- currency: { type: "string", description: "ISO 4217 currency code for this line (e.g. THB, USD, EUR). Defaults to THB.", default: "THB" },
71
- 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." },
72
98
  },
73
99
  required: ["account_id"],
74
100
  },
75
101
  },
76
102
  },
77
- required: ["date", "description", "lines"],
78
- },
79
- },
80
- {
81
- name: "note_concern",
82
- description: "Record a clarification request without pausing the run. Use during scan when a row is ambiguous (post your best-guess journal entry first, then call this with the entry's id), when a row is unparseable (skip the entry, call this with no entry_id), or when you have a concern about an account itself (pass account_id). The reviewer picks these up later with the full picture.",
83
- input_schema: {
84
- type: "object",
85
- properties: {
86
- prompt: {
87
- type: "string",
88
- description: "The question or concern in a complete sentence, with date, ฿-formatted amount, and human account names. Never reference internal ids.",
89
- },
90
- options: {
91
- type: "array",
92
- description: "Optional list of candidate answers the reviewer can offer the user.",
93
- items: { type: "string" },
94
- },
95
- entry_id: {
96
- type: "string",
97
- description: "Id of the journal entry this concern relates to (returned by record_journal_entry). Omit for file-level concerns about an unparseable row.",
98
- },
99
- account_id: {
100
- type: "string",
101
- 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 entry_id.",
102
- },
103
- },
104
- required: ["prompt"],
103
+ required: ["date", "description", "postings"],
105
104
  },
106
105
  },
107
106
  ];
108
- const SCAN_LABELS = {
107
+ const ACCOUNT_LABELS = {
109
108
  create_account: "Creating account",
110
109
  update_account_metadata: "Updating account metadata",
111
- record_journal_entry: "Posting journal entry",
112
- note_concern: "Noting concern",
110
+ record_transaction: "Posting transaction",
113
111
  };
114
- async function scanExecute(db, name, input, ctx) {
112
+ async function accountExecute(db, name, input, ctx) {
115
113
  switch (name) {
116
114
  case "create_account": {
117
115
  if (ctx?.dryRun)
@@ -119,20 +117,48 @@ async function scanExecute(db, name, input, ctx) {
119
117
  if (!ACCOUNT_TYPES.includes(input.type)) {
120
118
  return `Invalid type "${input.type}". Allowed: ${ACCOUNT_TYPES.join(", ")}.`;
121
119
  }
122
- const bank = input.bank_name ? String(input.bank_name).toUpperCase() : null;
123
- // Account writes serialize across concurrent scan agents so the next
124
- // list_accounts call (from any agent) sees this row.
125
120
  return await runAccountExclusive(() => {
126
121
  try {
127
- db.prepare(`INSERT INTO accounts (id, name, type, subtype, bank_name, account_number_masked, currency, due_day, statement_day, metadata_json)
128
- 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);
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
+ }
129
155
  return `Account created: ${input.id} (${input.name}, ${input.type}).`;
130
156
  }
131
157
  catch (err) {
132
- if (String(err.message).includes("UNIQUE")) {
158
+ if (err.code === "ACCOUNT_EXISTS") {
133
159
  return `Account "${input.id}" already exists. Use update_account_metadata to modify it.`;
134
160
  }
135
- throw err;
161
+ return `Could not create account "${input.id}": ${err.message}`;
136
162
  }
137
163
  });
138
164
  }
@@ -140,109 +166,199 @@ async function scanExecute(db, name, input, ctx) {
140
166
  if (ctx?.dryRun)
141
167
  return `Would update metadata for ${input.account_id}: ${JSON.stringify(input)}`;
142
168
  return await runAccountExclusive(() => {
143
- const acct = findAccountById(db, input.account_id);
144
- if (!acct)
145
- return `Account "${input.account_id}" not found.`;
146
- const updates = [];
147
- const params = [];
148
- if (input.due_day !== undefined) {
149
- updates.push("due_day = ?");
150
- params.push(input.due_day);
151
- }
152
- if (input.statement_day !== undefined) {
153
- updates.push("statement_day = ?");
154
- params.push(input.statement_day);
155
- }
156
- if (input.points_balance !== undefined) {
157
- updates.push("points_balance = ?");
158
- params.push(input.points_balance);
159
- }
160
- if (input.account_number_masked !== undefined) {
161
- updates.push("account_number_masked = ?");
162
- params.push(input.account_number_masked);
163
- }
164
- if (input.bank_name !== undefined) {
165
- updates.push("bank_name = ?");
166
- params.push(String(input.bank_name).toUpperCase());
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.";
167
203
  }
168
- if (input.metadata) {
169
- const existing = acct.metadata_json ? JSON.parse(acct.metadata_json) : {};
170
- const merged = { ...existing, ...input.metadata };
171
- updates.push("metadata_json = ?");
172
- params.push(JSON.stringify(merged));
204
+ catch (err) {
205
+ if (String(err.message).includes("not found")) {
206
+ return `Account "${input.account_id}" not found.`;
207
+ }
208
+ throw err;
173
209
  }
174
- if (updates.length === 0)
175
- return "Nothing to update.";
176
- params.push(input.account_id);
177
- db.prepare(`UPDATE accounts SET ${updates.join(", ")} WHERE id = ?`).run(...params);
178
- return `Updated ${input.account_id}.`;
179
210
  });
180
211
  }
181
- case "record_journal_entry": {
212
+ case "record_transaction": {
182
213
  if (!ctx)
183
- return "record_journal_entry is only available inside an agent session.";
214
+ return "record_transaction is only available inside an agent session.";
184
215
  if (ctx.dryRun)
185
- return `Would post journal entry "${input.description}" on ${input.date}.`;
186
- const entryInput = {
216
+ return `Would post transaction "${input.description}" on ${input.date}.`;
217
+ const txInput = {
187
218
  date: input.date,
188
219
  description: input.description,
189
220
  source_file_id: ctx.fileId,
190
221
  source_page: input.source_page ?? null,
191
- lines: (input.lines || []).map((l) => ({
192
- account_id: l.account_id,
193
- debit: l.debit ?? 0,
194
- credit: l.credit ?? 0,
195
- currency: l.currency || "THB",
196
- memo: l.memo ?? 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,
197
231
  })),
198
232
  };
199
233
  try {
200
- const entryId = ctx.buffer
201
- ? ctx.buffer.appendEntry(entryInput)
202
- : recordJournalEntry(db, entryInput);
203
- 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}).`;
204
266
  }
205
267
  catch (err) {
206
- return `Could not post journal entry: ${err.message}`;
268
+ return `Could not post transaction: ${err.message}`;
207
269
  }
208
270
  }
209
- case "note_concern": {
210
- if (!ctx)
211
- return "note_concern is only available inside an agent session.";
212
- const target = {
213
- entry_id: input.entry_id ?? null,
214
- account_id: input.account_id ?? null,
215
- };
216
- if (ctx.buffer) {
217
- ctx.buffer.appendConcern({ ...target, prompt: input.prompt, options: input.options });
218
- return `Concern noted (buffered). Continue with the next row.`;
219
- }
220
- const id = recordConcern(db, {
221
- file_id: ctx.fileId ?? null,
222
- entry_id: target.entry_id,
223
- account_id: target.account_id,
224
- prompt: input.prompt,
225
- options: input.options,
226
- });
227
- return `Concern noted (${id}). Continue with the next row.`;
228
- }
229
271
  default:
230
272
  return undefined;
231
273
  }
232
274
  }
233
- export const scanIngestTools = {
234
- DEFS: SCAN_DEFS,
235
- LABELS: SCAN_LABELS,
236
- execute: scanExecute,
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,
237
350
  };
238
- // ── Review-only tool definitions ────────────────────────────────────────────
239
- // `ask_user` is the only interactive primitive. Scan never reaches it (the
240
- // scan profile doesn't include this module), so we don't need a "scan, please
241
- // don't use this" guard.
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
+ */
242
358
  const REVIEW_DEFS = [
243
359
  {
244
360
  name: "ask_user",
245
- 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 `entry_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.",
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.",
246
362
  input_schema: {
247
363
  type: "object",
248
364
  properties: {
@@ -252,9 +368,9 @@ const REVIEW_DEFS = [
252
368
  description: "Optional list of candidate answers.",
253
369
  items: { type: "string" },
254
370
  },
255
- entry_id: {
371
+ transaction_id: {
256
372
  type: "string",
257
- description: "Optional: journal entry this question is about. Used to clear the entry's has_concern flag once all its concerns close.",
373
+ description: "Optional: transaction this question is about. Used to clear the transaction's has_concern flag once all its concerns close.",
258
374
  },
259
375
  account_id: {
260
376
  type: "string",
@@ -296,8 +412,6 @@ async function reviewExecute(db, name, input, ctx) {
296
412
  return undefined;
297
413
  if (!ctx)
298
414
  return "ask_user is only available inside an agent session.";
299
- // Two modes: resolve an existing concern in place (concern_id supplied),
300
- // or post a fresh question that becomes its own concerns row.
301
415
  let id;
302
416
  if (input.concern_id) {
303
417
  id = String(input.concern_id);
@@ -307,7 +421,7 @@ async function reviewExecute(db, name, input, ctx) {
307
421
  else {
308
422
  id = recordConcern(db, {
309
423
  file_id: ctx.fileId ?? null,
310
- entry_id: input.entry_id ?? null,
424
+ transaction_id: input.transaction_id ?? null,
311
425
  account_id: input.account_id ?? null,
312
426
  prompt: input.prompt,
313
427
  options: input.options,
@@ -316,9 +430,6 @@ async function reviewExecute(db, name, input, ctx) {
316
430
  if (ctx.interactive && ctx.promptUser) {
317
431
  const answer = await ctx.promptUser(input.prompt, input.options, input.facts);
318
432
  resolveConcern(db, id, answer);
319
- // Propagate the same answer to every sibling in the group so the user
320
- // isn't asked the same thing again. Skip the primary id if the agent
321
- // happened to include it.
322
433
  const siblings = Array.isArray(input.related_concern_ids) ? input.related_concern_ids : [];
323
434
  let propagated = 0;
324
435
  for (const sibId of siblings) {
@@ -0,0 +1,2 @@
1
+ import type { ToolModule } from "./types.js";
2
+ export declare const merchantTools: ToolModule;