plasalid 0.5.8 → 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 (88) 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 +7 -6
  5. package/dist/ai/agent.js +9 -8
  6. package/dist/ai/personas.d.ts +1 -1
  7. package/dist/ai/personas.js +69 -66
  8. package/dist/ai/prompt-sections.d.ts +4 -5
  9. package/dist/ai/prompt-sections.js +11 -11
  10. package/dist/ai/system-prompt.d.ts +2 -3
  11. package/dist/ai/system-prompt.js +5 -5
  12. package/dist/ai/tools/common.js +13 -5
  13. package/dist/ai/tools/index.js +15 -15
  14. package/dist/ai/tools/ingest.d.ts +2 -2
  15. package/dist/ai/tools/ingest.js +210 -87
  16. package/dist/ai/tools/merchants.js +27 -12
  17. package/dist/ai/tools/read.js +36 -20
  18. package/dist/ai/tools/record.js +79 -19
  19. package/dist/ai/tools/resolve.d.ts +2 -0
  20. package/dist/ai/tools/resolve.js +195 -0
  21. package/dist/ai/tools/types.d.ts +5 -7
  22. package/dist/cli/commands/accounts.js +2 -2
  23. package/dist/cli/commands/record.js +4 -2
  24. package/dist/cli/commands/resolve.d.ts +2 -0
  25. package/dist/cli/commands/resolve.js +13 -0
  26. package/dist/cli/commands/scan.js +18 -22
  27. package/dist/cli/commands/status.js +4 -2
  28. package/dist/cli/index.js +9 -9
  29. package/dist/cli/ink/hooks/useFooterText.js +1 -1
  30. package/dist/cli/ink/hooks/useTextInput.js +0 -3
  31. package/dist/cli/ink/scan_dashboard.d.ts +2 -2
  32. package/dist/cli/ink/scan_dashboard.js +3 -3
  33. package/dist/cli/setup.js +6 -3
  34. package/dist/cli/ux.js +1 -1
  35. package/dist/db/queries/account-balance.d.ts +140 -0
  36. package/dist/db/queries/account-balance.js +355 -0
  37. package/dist/db/queries/account_balance.d.ts +0 -1
  38. package/dist/db/queries/account_balance.js +0 -10
  39. package/dist/db/queries/action-log.d.ts +29 -0
  40. package/dist/db/queries/action-log.js +27 -0
  41. package/dist/db/queries/action_log.d.ts +1 -1
  42. package/dist/db/queries/concerns.d.ts +10 -0
  43. package/dist/db/queries/concerns.js +21 -0
  44. package/dist/db/queries/transactions.d.ts +3 -22
  45. package/dist/db/queries/transactions.js +4 -5
  46. package/dist/db/queries/unknowns.d.ts +62 -0
  47. package/dist/db/queries/unknowns.js +114 -0
  48. package/dist/db/schema.js +3 -3
  49. package/dist/resolver/pipeline.d.ts +16 -0
  50. package/dist/resolver/pipeline.js +38 -0
  51. package/dist/resolver/prompts.d.ts +8 -0
  52. package/dist/resolver/prompts.js +26 -0
  53. package/dist/scanner/account-mutex.d.ts +1 -0
  54. package/dist/scanner/account-mutex.js +16 -0
  55. package/dist/scanner/buffer.d.ts +10 -10
  56. package/dist/scanner/buffer.js +15 -15
  57. package/dist/scanner/decrypt-queue.d.ts +57 -0
  58. package/dist/scanner/decrypt-queue.js +114 -0
  59. package/dist/scanner/detectors/correlations.d.ts +2 -0
  60. package/dist/scanner/detectors/correlations.js +51 -0
  61. package/dist/scanner/detectors/duplicates.d.ts +2 -0
  62. package/dist/scanner/detectors/duplicates.js +75 -0
  63. package/dist/scanner/detectors/index.d.ts +18 -0
  64. package/dist/scanner/detectors/index.js +39 -0
  65. package/dist/scanner/detectors/recurrences.d.ts +2 -0
  66. package/dist/scanner/detectors/recurrences.js +49 -0
  67. package/dist/scanner/detectors/similar_accounts.d.ts +2 -0
  68. package/dist/scanner/detectors/similar_accounts.js +64 -0
  69. package/dist/scanner/detectors/similarities.d.ts +2 -0
  70. package/dist/scanner/detectors/similarities.js +73 -0
  71. package/dist/scanner/detectors/types.d.ts +16 -0
  72. package/dist/scanner/detectors/types.js +1 -0
  73. package/dist/scanner/inspectors/correlations.d.ts +2 -0
  74. package/dist/scanner/inspectors/correlations.js +47 -0
  75. package/dist/scanner/inspectors/duplicates.d.ts +2 -0
  76. package/dist/scanner/inspectors/duplicates.js +75 -0
  77. package/dist/scanner/inspectors/index.d.ts +19 -0
  78. package/dist/scanner/inspectors/index.js +39 -0
  79. package/dist/scanner/inspectors/recurrences.d.ts +2 -0
  80. package/dist/scanner/inspectors/recurrences.js +49 -0
  81. package/dist/scanner/inspectors/similarities.d.ts +2 -0
  82. package/dist/scanner/inspectors/similarities.js +73 -0
  83. package/dist/scanner/inspectors/types.d.ts +16 -0
  84. package/dist/scanner/inspectors/types.js +1 -0
  85. package/dist/scanner/pipeline.d.ts +6 -4
  86. package/dist/scanner/pipeline.js +51 -88
  87. package/dist/scanner/prompts.js +2 -2
  88. package/package.json +2 -1
@@ -1,8 +1,8 @@
1
1
  import { commonTools } from "./common.js";
2
2
  import { readTools } from "./read.js";
3
- import { accountIngestTools, scanConcernTools, reviewIngestTools } from "./ingest.js";
3
+ import { accountIngestTools, scanUnknownTools, resolveIngestTools } from "./ingest.js";
4
4
  import { scanTools } from "./scan.js";
5
- import { reviewTools } from "./review.js";
5
+ import { resolveTools } from "./resolve.js";
6
6
  import { recordTools } from "./record.js";
7
7
  import { merchantTools } from "./merchants.js";
8
8
  /**
@@ -11,17 +11,17 @@ import { merchantTools } from "./merchants.js";
11
11
  * central switch.
12
12
  *
13
13
  * `accountIngestTools` (create_account / update_account_metadata /
14
- * record_transaction) ships with scan, review, and record — they're the
15
- * shared write primitives. `scanConcernTools` (note_concern) is scan-only;
16
- * record uses `clarify` from `recordTools` for transient prompts, review uses
17
- * `ask_user` from `reviewIngestTools` for resolve-in-place clarifications.
18
- * `merchantTools` ships with scan, review, and record so any write profile can
14
+ * record_transaction) ships with scan, resolve, and record — they're the
15
+ * shared write primitives. `scanUnknownTools` (note_unknown) is scan-only;
16
+ * record uses `clarify` from `recordTools` for transient prompts, resolve uses
17
+ * `ask_user` from `resolveIngestTools` for resolve-in-place clarifications.
18
+ * `merchantTools` ships with scan, resolve, and record so any write profile can
19
19
  * upsert / look up / re-cache merchants alongside the posting flow.
20
20
  */
21
21
  const PROFILES = {
22
- scan: [commonTools, accountIngestTools, scanConcernTools, scanTools, merchantTools],
22
+ scan: [commonTools, accountIngestTools, scanUnknownTools, scanTools, merchantTools],
23
23
  chat: [commonTools, readTools],
24
- review: [commonTools, readTools, accountIngestTools, reviewIngestTools, reviewTools, merchantTools],
24
+ resolve: [commonTools, readTools, accountIngestTools, resolveIngestTools, resolveTools, merchantTools],
25
25
  record: [commonTools, readTools, accountIngestTools, recordTools, merchantTools],
26
26
  };
27
27
  export function getToolDefinitions(profile) {
@@ -32,10 +32,10 @@ export async function executeTool(db, name, input, ctx) {
32
32
  commonTools,
33
33
  readTools,
34
34
  accountIngestTools,
35
- scanConcernTools,
36
- reviewIngestTools,
35
+ scanUnknownTools,
36
+ resolveIngestTools,
37
37
  scanTools,
38
- reviewTools,
38
+ resolveTools,
39
39
  recordTools,
40
40
  merchantTools,
41
41
  ]) {
@@ -50,10 +50,10 @@ export const TOOL_LABELS = {
50
50
  ...commonTools.LABELS,
51
51
  ...readTools.LABELS,
52
52
  ...accountIngestTools.LABELS,
53
- ...scanConcernTools.LABELS,
54
- ...reviewIngestTools.LABELS,
53
+ ...scanUnknownTools.LABELS,
54
+ ...resolveIngestTools.LABELS,
55
55
  ...scanTools.LABELS,
56
- ...reviewTools.LABELS,
56
+ ...resolveTools.LABELS,
57
57
  ...recordTools.LABELS,
58
58
  ...merchantTools.LABELS,
59
59
  };
@@ -1,4 +1,4 @@
1
1
  import type { ToolModule } from "./types.js";
2
2
  export declare const accountIngestTools: ToolModule;
3
- export declare const scanConcernTools: ToolModule;
4
- export declare const reviewIngestTools: ToolModule;
3
+ export declare const scanUnknownTools: ToolModule;
4
+ export declare const resolveIngestTools: ToolModule;
@@ -1,18 +1,18 @@
1
- import { createAccount, updateAccountMetadata, findAccountById, } from "../../db/queries/account_balance.js";
1
+ import { createAccount, updateAccountMetadata, findAccountById, } from "../../db/queries/account-balance.js";
2
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";
3
+ import { appendAction } from "../../db/queries/action-log.js";
4
+ import { getUnknownTarget, recordUnknown, resolveUnknown, } from "../../db/queries/unknowns.js";
5
+ import { runExclusive as runAccountExclusive } from "../../scanner/account-mutex.js";
6
6
  import { sanitizeForPrompt } from "../sanitize.js";
7
7
  import { ACCOUNT_TYPE_DESCRIPTIONS } from "../../accounts/taxonomy.js";
8
8
  const ACCOUNT_TYPES = Object.keys(ACCOUNT_TYPE_DESCRIPTIONS);
9
9
  /**
10
10
  * Account + transaction write primitives
11
11
  *
12
- * Shared by scan, review, and record. Each tool branches once on
12
+ * Shared by scan, resolve, and record. Each tool branches once on
13
13
  * `ctx.correlationId`: when set (record path), the data write and the
14
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
15
+ * atomic with the change. Without it (scan / resolve), the write goes through
16
16
  * the existing path unchanged.
17
17
  */
18
18
  const ACCOUNT_DEFS = [
@@ -22,20 +22,52 @@ const ACCOUNT_DEFS = [
22
22
  input_schema: {
23
23
  type: "object",
24
24
  properties: {
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." },
25
+ id: {
26
+ type: "string",
27
+ description: "Stable colon-path identifier, lowercase. e.g. 'expense:food:groceries' or 'asset:kbank-savings-1234'.",
28
+ },
29
+ name: {
30
+ type: "string",
31
+ description: "Human-readable name. e.g. 'Groceries' or 'KBank Savings ••1234'.",
32
+ },
33
+ type: {
34
+ type: "string",
35
+ enum: ACCOUNT_TYPES,
36
+ description: "Account type. Must match the parent's type.",
37
+ },
28
38
  parent_id: {
29
39
  type: ["string", "null"],
30
40
  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
41
  },
32
- subtype: { type: "string", description: "e.g. 'bank', 'credit_card', 'salary'." },
33
- bank_name: { type: "string", description: "Thai institution code from the taxonomy (e.g. KBANK, SCB, KTC)." },
34
- account_number_masked: { type: "string", description: "Last 4 digits only, e.g. '••1234'." },
35
- currency: { type: "string", description: "ISO 4217 code. Defaults to 'THB'.", default: "THB" },
36
- due_day: { type: "number", description: "Credit-card due day of month (liabilities only)." },
37
- statement_day: { type: "number", description: "Statement-cut day of month." },
38
- metadata: { type: "object", description: "Free-form extra fields (e.g. {points_program: 'KTC Forever'})." },
42
+ subtype: {
43
+ type: "string",
44
+ description: "e.g. 'bank', 'credit_card', 'salary'.",
45
+ },
46
+ bank_name: {
47
+ type: "string",
48
+ description: "Thai institution code from the taxonomy (e.g. KBANK, SCB, KTC).",
49
+ },
50
+ account_number_masked: {
51
+ type: "string",
52
+ description: "Last 4 digits only, e.g. '••1234'.",
53
+ },
54
+ currency: {
55
+ type: "string",
56
+ description: "ISO 4217 code. Defaults to 'THB'.",
57
+ default: "THB",
58
+ },
59
+ due_day: {
60
+ type: "number",
61
+ description: "Credit-card due day of month (liabilities only).",
62
+ },
63
+ statement_day: {
64
+ type: "number",
65
+ description: "Statement-cut day of month.",
66
+ },
67
+ metadata: {
68
+ type: "object",
69
+ description: "Free-form extra fields (e.g. {points_program: 'KTC Forever'}).",
70
+ },
39
71
  },
40
72
  required: ["id", "name", "type", "parent_id"],
41
73
  },
@@ -52,7 +84,10 @@ const ACCOUNT_DEFS = [
52
84
  points_balance: { type: "number" },
53
85
  account_number_masked: { type: "string" },
54
86
  bank_name: { type: "string" },
55
- metadata: { type: "object", description: "Merged into existing metadata_json." },
87
+ metadata: {
88
+ type: "object",
89
+ description: "Merged into existing metadata_json.",
90
+ },
56
91
  },
57
92
  required: ["account_id"],
58
93
  },
@@ -63,9 +98,18 @@ const ACCOUNT_DEFS = [
63
98
  input_schema: {
64
99
  type: "object",
65
100
  properties: {
66
- date: { type: "string", description: "ISO Gregorian date (YYYY-MM-DD)." },
67
- description: { type: "string", description: "Short human-readable description." },
68
- source_page: { type: "number", description: "Page number in the source PDF, if known." },
101
+ date: {
102
+ type: "string",
103
+ description: "ISO Gregorian date (YYYY-MM-DD).",
104
+ },
105
+ description: {
106
+ type: "string",
107
+ description: "Short human-readable description.",
108
+ },
109
+ source_page: {
110
+ type: "number",
111
+ description: "Page number in the source PDF, if known.",
112
+ },
69
113
  raw_descriptor: {
70
114
  type: "string",
71
115
  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.",
@@ -74,9 +118,18 @@ const ACCOUNT_DEFS = [
74
118
  type: "object",
75
119
  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
120
  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." },
121
+ canonical_name: {
122
+ type: "string",
123
+ description: "Normalized merchant name, Title Case. e.g. 'Starbucks', 'Amazon', 'Spotify'.",
124
+ },
125
+ alias: {
126
+ type: "string",
127
+ description: "The raw descriptor exactly as it appears on the statement. Plasalid normalizes and stores it so future statements skip the LLM.",
128
+ },
129
+ default_account_id: {
130
+ type: "string",
131
+ description: "Optional learned cache: 'this merchant's expense category is X'. Set when categorization is confident.",
132
+ },
80
133
  },
81
134
  required: ["canonical_name"],
82
135
  },
@@ -90,11 +143,27 @@ const ACCOUNT_DEFS = [
90
143
  items: {
91
144
  type: "object",
92
145
  properties: {
93
- account_id: { type: "string", description: "Existing account id from list_accounts or create_account." },
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." },
146
+ account_id: {
147
+ type: "string",
148
+ description: "Existing account id from list_accounts or create_account.",
149
+ },
150
+ debit: {
151
+ type: "number",
152
+ description: "Debit amount in this posting's currency. Use 0 if this posting is a credit.",
153
+ },
154
+ credit: {
155
+ type: "number",
156
+ description: "Credit amount in this posting's currency. Use 0 if this posting is a debit.",
157
+ },
158
+ currency: {
159
+ type: "string",
160
+ description: "ISO 4217 currency code for this posting (e.g. THB, USD, EUR). Defaults to THB.",
161
+ default: "THB",
162
+ },
163
+ memo: {
164
+ type: "string",
165
+ description: "Optional per-posting memo.",
166
+ },
98
167
  },
99
168
  required: ["account_id"],
100
169
  },
@@ -112,8 +181,6 @@ const ACCOUNT_LABELS = {
112
181
  async function accountExecute(db, name, input, ctx) {
113
182
  switch (name) {
114
183
  case "create_account": {
115
- if (ctx?.dryRun)
116
- return `Would create account ${input.id}.`;
117
184
  if (!ACCOUNT_TYPES.includes(input.type)) {
118
185
  return `Invalid type "${input.type}". Allowed: ${ACCOUNT_TYPES.join(", ")}.`;
119
186
  }
@@ -163,8 +230,6 @@ async function accountExecute(db, name, input, ctx) {
163
230
  });
164
231
  }
165
232
  case "update_account_metadata": {
166
- if (ctx?.dryRun)
167
- return `Would update metadata for ${input.account_id}: ${JSON.stringify(input)}`;
168
233
  return await runAccountExclusive(() => {
169
234
  try {
170
235
  let changed = false;
@@ -199,7 +264,9 @@ async function accountExecute(db, name, input, ctx) {
199
264
  else {
200
265
  apply();
201
266
  }
202
- return changed ? `Updated ${input.account_id}.` : "Nothing to update.";
267
+ return changed
268
+ ? `Updated ${input.account_id}.`
269
+ : "Nothing to update.";
203
270
  }
204
271
  catch (err) {
205
272
  if (String(err.message).includes("not found")) {
@@ -212,8 +279,6 @@ async function accountExecute(db, name, input, ctx) {
212
279
  case "record_transaction": {
213
280
  if (!ctx)
214
281
  return "record_transaction is only available inside an agent session.";
215
- if (ctx.dryRun)
216
- return `Would post transaction "${input.description}" on ${input.date}.`;
217
282
  const txInput = {
218
283
  date: input.date,
219
284
  description: input.description,
@@ -278,62 +343,67 @@ export const accountIngestTools = {
278
343
  execute: accountExecute,
279
344
  };
280
345
  /**
281
- * Scan-only concerns
346
+ * Scan-only unknowns
282
347
  *
283
- * `note_concern` records a clarification mid-scan without ever prompting the
348
+ * `note_unknown` records a clarification mid-scan without ever prompting the
284
349
  * user — only scan needs this. Record uses `clarify` (transient prompt, no
285
- * concerns-table residue); review uses `ask_user` (prompts and resolves).
350
+ * unknowns-table residue); resolve uses `ask_user` (prompts and resolves).
286
351
  */
287
- const CONCERN_DEFS = [
352
+ const UNKNOWN_DEFS = [
288
353
  {
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.",
354
+ name: "note_unknown",
355
+ 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 unknown about an account itself (pass account_id). Use kind='uncategorized_expense' when posting an expense to expense:uncategorized so resolve can group these. The resolver picks these up later with the full picture.",
291
356
  input_schema: {
292
357
  type: "object",
293
358
  properties: {
294
359
  prompt: {
295
360
  type: "string",
296
- description: "The question or concern in a complete sentence, with date, ฿-formatted amount, and human account names. Never reference internal ids.",
361
+ description: "The question or unknown in a complete sentence, with date, ฿-formatted amount, and human account names. Never reference internal ids.",
297
362
  },
298
363
  kind: {
299
364
  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.",
365
+ description: "Optional category for the unknown. Use 'uncategorized_expense' when the posting landed in expense:uncategorized; the resolver batches these into one cleanup pass.",
301
366
  },
302
367
  options: {
303
368
  type: "array",
304
- description: "Optional list of candidate answers the reviewer can offer the user.",
369
+ description: "Optional list of candidate answers the resolver can offer the user.",
305
370
  items: { type: "string" },
306
371
  },
307
372
  transaction_id: {
308
373
  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.",
374
+ description: "Id of the transaction this unknown relates to (returned by record_transaction). Omit for file-level unknowns about an unparseable row.",
310
375
  },
311
376
  account_id: {
312
377
  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.",
378
+ description: "Id of the account this unknown 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
379
  },
315
380
  },
316
381
  required: ["prompt"],
317
382
  },
318
383
  },
319
384
  ];
320
- const CONCERN_LABELS = {
321
- note_concern: "Noting concern",
385
+ const UNKNOWN_LABELS = {
386
+ note_unknown: "Noting unknown",
322
387
  };
323
- async function concernExecute(db, name, input, ctx) {
324
- if (name !== "note_concern")
388
+ async function unknownExecute(db, name, input, ctx) {
389
+ if (name !== "note_unknown")
325
390
  return undefined;
326
391
  if (!ctx)
327
- return "note_concern is only available inside an agent session.";
392
+ return "note_unknown is only available inside an agent session.";
328
393
  const target = {
329
394
  transaction_id: input.transaction_id ?? null,
330
395
  account_id: input.account_id ?? null,
331
396
  };
332
397
  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.`;
398
+ ctx.buffer.appendUnknown({
399
+ ...target,
400
+ kind: input.kind ?? null,
401
+ prompt: input.prompt,
402
+ options: input.options,
403
+ });
404
+ return `Unknown noted (buffered). Continue with the next row.`;
335
405
  }
336
- const id = recordConcern(db, {
406
+ const id = recordUnknown(db, {
337
407
  file_id: ctx.fileId ?? null,
338
408
  transaction_id: target.transaction_id,
339
409
  account_id: target.account_id,
@@ -341,28 +411,31 @@ async function concernExecute(db, name, input, ctx) {
341
411
  prompt: input.prompt,
342
412
  options: input.options,
343
413
  });
344
- return `Concern noted (${id}). Continue with the next row.`;
414
+ return `Unknown noted (${id}). Continue with the next row.`;
345
415
  }
346
- export const scanConcernTools = {
347
- DEFS: CONCERN_DEFS,
348
- LABELS: CONCERN_LABELS,
349
- execute: concernExecute,
416
+ export const scanUnknownTools = {
417
+ DEFS: UNKNOWN_DEFS,
418
+ LABELS: UNKNOWN_LABELS,
419
+ execute: unknownExecute,
350
420
  };
351
421
  /**
352
- * Review-only tool definitions
422
+ * Resolve-only tool definitions
353
423
  *
354
424
  * `ask_user` is the only interactive primitive. Scan never reaches it (the
355
425
  * scan profile doesn't include this module), so we don't need a "scan, please
356
426
  * don't use this" guard.
357
427
  */
358
- const REVIEW_DEFS = [
428
+ const RESOLVE_DEFS = [
359
429
  {
360
430
  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.",
431
+ description: "Ask the user a clarifying question when you cannot confidently proceed. The pipeline pauses and prompts the user interactively. Available during `plasalid resolve`. Not exposed during `plasalid scan` — use `note_unknown` instead. Pass `transaction_id` / `account_id` to attach the question to the same target as a scan-noted unknown. Pass `unknown_id` to resolve an existing open unknown in place (recommended when re-posing a scan-noted unknown to the user). Pass `related_unknown_ids` to apply the user's single answer to a whole group of sibling unknowns at once.",
362
432
  input_schema: {
363
433
  type: "object",
364
434
  properties: {
365
- prompt: { type: "string", description: "The question to ask in plain language." },
435
+ prompt: {
436
+ type: "string",
437
+ description: "The question to ask in plain language.",
438
+ },
366
439
  options: {
367
440
  type: "array",
368
441
  description: "Optional list of candidate answers.",
@@ -370,28 +443,37 @@ const REVIEW_DEFS = [
370
443
  },
371
444
  transaction_id: {
372
445
  type: "string",
373
- description: "Optional: transaction this question is about. Used to clear the transaction's has_concern flag once all its concerns close.",
446
+ description: "Optional: transaction this question is about. Used to clear the transaction's has_unknown flag once all its unknowns close.",
374
447
  },
375
448
  account_id: {
376
449
  type: "string",
377
- description: "Optional: account this question is about. Used to clear the account's has_concern flag once all its concerns close.",
450
+ description: "Optional: account this question is about. Used to clear the account's has_unknown flag once all its unknowns close.",
378
451
  },
379
- concern_id: {
452
+ unknown_id: {
380
453
  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.",
454
+ description: "Optional: id of an existing open unknown. If supplied, the user's answer resolves that row in place instead of creating a new one.",
382
455
  },
383
- related_concern_ids: {
456
+ related_unknown_ids: {
384
457
  type: "array",
385
458
  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.",
459
+ description: "Optional: ids of additional open unknowns that share the same answer as `unknown_id`. The user is prompted once; every listed unknown (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
460
  },
388
461
  facts: {
389
462
  type: "object",
390
463
  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
464
  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'." },
465
+ amount: {
466
+ type: "string",
467
+ description: "฿-formatted amount, e.g. '฿1,200.00'.",
468
+ },
469
+ date: {
470
+ type: "string",
471
+ description: "ISO date or short range, e.g. '2026-04-15' or '2026-02-15 to 2026-05-15'.",
472
+ },
473
+ merchant: {
474
+ type: "string",
475
+ description: "Counterparty / merchant name, e.g. 'LAZADA TH', 'Spotify'.",
476
+ },
395
477
  accounts: {
396
478
  type: "array",
397
479
  items: { type: "string" },
@@ -403,23 +485,42 @@ const REVIEW_DEFS = [
403
485
  required: ["prompt"],
404
486
  },
405
487
  },
488
+ {
489
+ name: "close_unknown",
490
+ description: "Close an open unknown by writing its answer to the row WITHOUT prompting the user. Use after applying a mutation that a memory rule, heuristic, or small-amount auto-skip already implied. Pass `related_unknown_ids` to close a sibling group in one call. The actual mutation (update_posting / record_recurrence / merge_accounts / etc.) must be done BEFORE this call — close_unknown only records the answer for audit.",
491
+ input_schema: {
492
+ type: "object",
493
+ properties: {
494
+ unknown_id: { type: "string" },
495
+ answer: {
496
+ type: "string",
497
+ description: "The implied answer to record.",
498
+ },
499
+ related_unknown_ids: { type: "array", items: { type: "string" } },
500
+ },
501
+ required: ["unknown_id", "answer"],
502
+ },
503
+ },
406
504
  ];
407
- const REVIEW_LABELS = {
505
+ const RESOLVE_LABELS = {
408
506
  ask_user: "Asking for clarification",
507
+ close_unknown: "Closing unknown",
409
508
  };
410
- async function reviewExecute(db, name, input, ctx) {
509
+ async function resolveExecute(db, name, input, ctx) {
510
+ if (name === "close_unknown")
511
+ return closeUnknown(db, input);
411
512
  if (name !== "ask_user")
412
513
  return undefined;
413
514
  if (!ctx)
414
515
  return "ask_user is only available inside an agent session.";
415
516
  let id;
416
- if (input.concern_id) {
417
- id = String(input.concern_id);
418
- if (!getConcernTarget(db, id))
419
- return `Concern ${id} not found.`;
517
+ if (input.unknown_id) {
518
+ id = String(input.unknown_id);
519
+ if (!getUnknownTarget(db, id))
520
+ return `Unknown ${id} not found.`;
420
521
  }
421
522
  else {
422
- id = recordConcern(db, {
523
+ id = recordUnknown(db, {
423
524
  file_id: ctx.fileId ?? null,
424
525
  transaction_id: input.transaction_id ?? null,
425
526
  account_id: input.account_id ?? null,
@@ -429,22 +530,44 @@ async function reviewExecute(db, name, input, ctx) {
429
530
  }
430
531
  if (ctx.interactive && ctx.promptUser) {
431
532
  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 : [];
533
+ resolveUnknown(db, id, answer);
534
+ const siblings = Array.isArray(input.related_unknown_ids)
535
+ ? input.related_unknown_ids
536
+ : [];
434
537
  let propagated = 0;
435
538
  for (const sibId of siblings) {
436
539
  if (sibId === id)
437
540
  continue;
438
- if (resolveConcern(db, String(sibId), answer))
541
+ if (resolveUnknown(db, String(sibId), answer))
439
542
  propagated++;
440
543
  }
441
544
  const totalResolved = 1 + propagated;
442
- return `User answered: ${sanitizeForPrompt(answer)}${totalResolved > 1 ? ` (applied to ${totalResolved} concern${totalResolved === 1 ? "" : "s"})` : ""}`;
545
+ return `User answered: ${sanitizeForPrompt(answer)}${totalResolved > 1 ? ` (applied to ${totalResolved} unknown${totalResolved === 1 ? "" : "s"})` : ""}`;
443
546
  }
444
547
  return `Question recorded for later (${id}). Awaiting user input — do not act on assumptions about this answer.`;
445
548
  }
446
- export const reviewIngestTools = {
447
- DEFS: REVIEW_DEFS,
448
- LABELS: REVIEW_LABELS,
449
- execute: reviewExecute,
549
+ function closeUnknown(db, input) {
550
+ const primary = String(input.unknown_id ?? "");
551
+ const answer = String(input.answer ?? "");
552
+ if (!primary || !answer)
553
+ return "close_unknown requires unknown_id and answer.";
554
+ if (!getUnknownTarget(db, primary))
555
+ return `Unknown ${primary} not found.`;
556
+ resolveUnknown(db, primary, answer);
557
+ let count = 1;
558
+ const siblings = Array.isArray(input.related_unknown_ids)
559
+ ? input.related_unknown_ids
560
+ : [];
561
+ for (const sibId of siblings) {
562
+ if (sibId === primary)
563
+ continue;
564
+ if (resolveUnknown(db, String(sibId), answer))
565
+ count++;
566
+ }
567
+ return `Closed ${count} unknown${count === 1 ? "" : "s"}.`;
568
+ }
569
+ export const resolveIngestTools = {
570
+ DEFS: RESOLVE_DEFS,
571
+ LABELS: RESOLVE_LABELS,
572
+ execute: resolveExecute,
450
573
  };
@@ -1,5 +1,5 @@
1
1
  import { upsertMerchant, findMerchantByAlias, findMerchantById, setMerchantDefaultAccount, } from "../../db/queries/merchants.js";
2
- import { appendAction } from "../../db/queries/action_log.js";
2
+ import { appendAction } from "../../db/queries/action-log.js";
3
3
  import { sanitizeForPrompt } from "../sanitize.js";
4
4
  /**
5
5
  * Merchant tools
@@ -17,9 +17,18 @@ const DEFS = [
17
17
  input_schema: {
18
18
  type: "object",
19
19
  properties: {
20
- canonical_name: { type: "string", description: "Title-cased merchant name, e.g. 'Starbucks', 'Amazon'." },
21
- alias: { type: "string", description: "Optional raw descriptor (as seen on a statement). Plasalid normalizes and dedups it." },
22
- default_account_id: { type: "string", description: "Optional learned cache: the merchant's default expense account." },
20
+ canonical_name: {
21
+ type: "string",
22
+ description: "Title-cased merchant name, e.g. 'Starbucks', 'Amazon'.",
23
+ },
24
+ alias: {
25
+ type: "string",
26
+ description: "Optional raw descriptor (as seen on a statement). Plasalid normalizes and dedups it.",
27
+ },
28
+ default_account_id: {
29
+ type: "string",
30
+ description: "Optional learned cache: the merchant's default expense account.",
31
+ },
23
32
  },
24
33
  required: ["canonical_name"],
25
34
  },
@@ -30,7 +39,10 @@ const DEFS = [
30
39
  input_schema: {
31
40
  type: "object",
32
41
  properties: {
33
- descriptor: { type: "string", description: "The raw statement line or merchant string to look up." },
42
+ descriptor: {
43
+ type: "string",
44
+ description: "The raw statement line or merchant string to look up.",
45
+ },
34
46
  },
35
47
  required: ["descriptor"],
36
48
  },
@@ -56,8 +68,6 @@ const LABELS = {
56
68
  async function execute(db, name, input, ctx) {
57
69
  switch (name) {
58
70
  case "find_or_create_merchant": {
59
- if (ctx?.dryRun)
60
- return `Would upsert merchant "${input.canonical_name}".`;
61
71
  const existing = db
62
72
  .prepare(`SELECT id FROM merchants WHERE canonical_name = ?`)
63
73
  .get(input.canonical_name);
@@ -73,22 +83,27 @@ async function execute(db, name, input, ctx) {
73
83
  user_input: ctx.userInput ?? null,
74
84
  action_type: "create_merchant",
75
85
  target_id: merchant.id,
76
- payload: { canonical_name: merchant.canonical_name, default_account_id: merchant.default_account_id },
86
+ payload: {
87
+ canonical_name: merchant.canonical_name,
88
+ default_account_id: merchant.default_account_id,
89
+ },
77
90
  });
78
91
  }
79
- const defaultStr = merchant.default_account_id ? ` (default → ${merchant.default_account_id})` : "";
92
+ const defaultStr = merchant.default_account_id
93
+ ? ` (default → ${merchant.default_account_id})`
94
+ : "";
80
95
  return `Merchant ${merchant.id}: ${sanitizeForPrompt(merchant.canonical_name)}${defaultStr}.`;
81
96
  }
82
97
  case "find_merchant_by_descriptor": {
83
98
  const hit = findMerchantByAlias(db, String(input.descriptor ?? ""));
84
99
  if (!hit)
85
100
  return `No merchant matched descriptor "${sanitizeForPrompt(String(input.descriptor ?? ""))}".`;
86
- const defaultStr = hit.default_account_id ? ` (default → ${hit.default_account_id})` : "";
101
+ const defaultStr = hit.default_account_id
102
+ ? ` (default → ${hit.default_account_id})`
103
+ : "";
87
104
  return `Merchant ${hit.merchant.id}: ${sanitizeForPrompt(hit.merchant.canonical_name)}${defaultStr}.`;
88
105
  }
89
106
  case "set_merchant_default_account": {
90
- if (ctx?.dryRun)
91
- return `Would set ${input.merchant_id}'s default to ${input.account_id}.`;
92
107
  const m = findMerchantById(db, input.merchant_id);
93
108
  if (!m)
94
109
  return `Merchant ${input.merchant_id} not found.`;