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,51 +1,48 @@
1
1
  import { deleteAccount, findSimilarAccounts, findUnusedAccounts, mergeAccounts, renameAccount, } from "../../db/queries/account_balance.js";
2
- import { deleteJournalEntry, findCorrelatedEntries, findDuplicateEntries, updateJournalEntry, updateJournalLine, } from "../../db/queries/journal.js";
3
- import { findRecurrenceCandidates, linkEntryToRecurrence, recordRecurrence, } from "../../db/queries/recurrences.js";
4
- import { formatCurrencyAmount } from "../../currency.js";
2
+ import { deleteTransaction, findCorrelatedTransactions, findDuplicateTransactions, updateTransaction, updatePosting, } from "../../db/queries/transactions.js";
3
+ import { findRecurrenceCandidates, linkTransactionToRecurrence, recordRecurrence, } from "../../db/queries/recurrences.js";
4
+ import { formatAmount } from "../../currency.js";
5
5
  import { sanitizeForPrompt } from "../sanitize.js";
6
- function formatTHB(amount) {
7
- return formatCurrencyAmount(amount, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
8
- }
9
6
  const DEFS = [
10
7
  {
11
- name: "update_journal_entry",
12
- description: "Header-only update: date, description, or source_page. To change amounts, delete the entry and record a new one.",
8
+ name: "update_transaction",
9
+ description: "Header-only update: date, description, or source_page. To change amounts, delete the transaction and record a new one.",
13
10
  input_schema: {
14
11
  type: "object",
15
12
  properties: {
16
- entry_id: { type: "string" },
13
+ transaction_id: { type: "string" },
17
14
  date: { type: "string" },
18
15
  description: { type: "string" },
19
16
  source_page: { type: "number" },
20
17
  },
21
- required: ["entry_id"],
18
+ required: ["transaction_id"],
22
19
  },
23
20
  },
24
21
  {
25
- name: "update_journal_line",
26
- description: "Safe single-line edit: re-categorize (account_id) or update memo. Refuses changes to debit/credit/currency — delete and re-record the entry for those.",
22
+ name: "update_posting",
23
+ description: "Safe single-posting edit: re-categorize (account_id) or update memo. Refuses changes to debit/credit/currency — delete and re-record the transaction for those.",
27
24
  input_schema: {
28
25
  type: "object",
29
26
  properties: {
30
- line_id: { type: "string" },
27
+ posting_id: { type: "string" },
31
28
  account_id: { type: "string" },
32
29
  memo: { type: "string" },
33
30
  },
34
- required: ["line_id"],
31
+ required: ["posting_id"],
35
32
  },
36
33
  },
37
34
  {
38
- name: "delete_journal_entry",
39
- description: "Delete an entry and (via cascade) all its lines. The primitive for removing duplicates.",
35
+ name: "delete_transaction",
36
+ description: "Delete a transaction and (via cascade) all its postings. The primitive for removing duplicates.",
40
37
  input_schema: {
41
38
  type: "object",
42
- properties: { entry_id: { type: "string" } },
43
- required: ["entry_id"],
39
+ properties: { transaction_id: { type: "string" } },
40
+ required: ["transaction_id"],
44
41
  },
45
42
  },
46
43
  {
47
44
  name: "rename_account",
48
- description: "Rename an account. Leaves lines and metadata untouched.",
45
+ description: "Rename an account. Leaves postings and metadata untouched.",
49
46
  input_schema: {
50
47
  type: "object",
51
48
  properties: { account_id: { type: "string" }, name: { type: "string" } },
@@ -54,7 +51,7 @@ const DEFS = [
54
51
  },
55
52
  {
56
53
  name: "merge_accounts",
57
- description: "Move every journal line on `from_id` over to `to_id`, then delete the source account. Use to collapse duplicate accounts.",
54
+ description: "Move every posting on `from_id` over to `to_id`, then delete the source account. Use to collapse duplicate accounts. Refuses if the source still has child accounts.",
58
55
  input_schema: {
59
56
  type: "object",
60
57
  properties: { from_id: { type: "string" }, to_id: { type: "string" } },
@@ -63,7 +60,7 @@ const DEFS = [
63
60
  },
64
61
  {
65
62
  name: "delete_account",
66
- description: "Delete an account that has no journal lines. Refuses if any line still references it — merge first.",
63
+ description: "Delete an account that has no postings and no children. Refuses if any posting or child still references it — merge first.",
67
64
  input_schema: {
68
65
  type: "object",
69
66
  properties: { account_id: { type: "string" } },
@@ -71,8 +68,8 @@ const DEFS = [
71
68
  },
72
69
  },
73
70
  {
74
- name: "find_duplicate_entries",
75
- description: "Heuristic: groups journal entries by total amount and a configurable date tolerance. Returns groups with two or more candidate dupes.",
71
+ name: "find_duplicate_transactions",
72
+ description: "Heuristic: groups transactions by total amount and a configurable date tolerance. Returns groups with two or more candidate dupes.",
76
73
  input_schema: {
77
74
  type: "object",
78
75
  properties: {
@@ -94,18 +91,18 @@ const DEFS = [
94
91
  },
95
92
  {
96
93
  name: "find_unused_accounts",
97
- description: "Accounts with zero linked journal lines.",
94
+ description: "Accounts with zero postings and no children (excludes the five top-level type roots).",
98
95
  input_schema: { type: "object", properties: {}, required: [] },
99
96
  },
100
97
  {
101
- name: "find_correlated_entries",
102
- description: "Surface pairs of entries that look like the same money movement recorded against different accounts (a transfer recorded once on each statement). Pairs are filtered: same amount + currency, within `tolerance_days` of each other, and the two entries share no account_ids (overlap → duplicate, not correlation).",
98
+ name: "find_correlated_transactions",
99
+ description: "Surface pairs of transactions that look like the same money movement recorded against different accounts (a transfer recorded once on each statement). Pairs are filtered: same amount + currency, within `tolerance_days` of each other, and the two transactions share no account_ids (overlap → duplicate, not correlation).",
103
100
  input_schema: {
104
101
  type: "object",
105
102
  properties: {
106
103
  from: { type: "string", description: "ISO date inclusive lower bound (YYYY-MM-DD)." },
107
104
  to: { type: "string", description: "ISO date inclusive upper bound (YYYY-MM-DD)." },
108
- tolerance_days: { type: "number", default: 3, description: "Max day gap between paired entries." },
105
+ tolerance_days: { type: "number", default: 3, description: "Max day gap between paired transactions." },
109
106
  min_amount: { type: "number", default: 0 },
110
107
  },
111
108
  required: [],
@@ -113,7 +110,7 @@ const DEFS = [
113
110
  },
114
111
  {
115
112
  name: "find_recurrences",
116
- description: "Detect candidate recurring transactions by grouping unlinked entries on the same account + amount + side (debit/credit), then classifying cadence (weekly/biweekly/monthly/annually/irregular) from the median gap between consecutive dates. Skips entries already linked to a recurrence.",
113
+ description: "Detect candidate recurring transactions by grouping unlinked transactions on the same account + amount + side (debit/credit), then classifying cadence (weekly/biweekly/monthly/annually/irregular) from the median gap between consecutive dates. Skips transactions already linked to a recurrence.",
117
114
  input_schema: {
118
115
  type: "object",
119
116
  properties: {
@@ -125,31 +122,31 @@ const DEFS = [
125
122
  },
126
123
  {
127
124
  name: "record_recurrence",
128
- description: "Create a recurrences row and link every supplied journal entry to it. Computes first_seen_date, last_seen_date, and next_expected_date from the member entries. Use this after the user confirms a recurrence candidate.",
125
+ description: "Create a recurrences row and link every supplied transaction to it. Computes first_seen_date, last_seen_date, and next_expected_date from the member transactions. Use this after the user confirms a recurrence candidate.",
129
126
  input_schema: {
130
127
  type: "object",
131
128
  properties: {
132
129
  account_id: { type: "string", description: "The account this recurs on." },
133
130
  description: { type: "string", description: "Human label, e.g. 'Spotify subscription', 'Salary', 'Rent'." },
134
131
  frequency: { type: "string", enum: ["weekly", "biweekly", "monthly", "annually"] },
135
- amount_typical: { type: "number", description: "Representative amount (typically the matching amount of the member entries)." },
132
+ amount_typical: { type: "number", description: "Representative amount (typically the matching amount of the member transactions)." },
136
133
  currency: { type: "string", default: "THB" },
137
- entry_ids: { type: "array", items: { type: "string" }, description: "Journal entry ids to link to this recurrence." },
134
+ transaction_ids: { type: "array", items: { type: "string" }, description: "Transaction ids to link to this recurrence." },
138
135
  notes: { type: "string", description: "Optional context the chat agent can read later." },
139
136
  },
140
- required: ["account_id", "description", "frequency", "entry_ids"],
137
+ required: ["account_id", "description", "frequency", "transaction_ids"],
141
138
  },
142
139
  },
143
140
  {
144
- name: "link_entry_to_recurrence",
145
- description: "Attach a single newly-seen entry to an existing recurrence. Recomputes last_seen_date and next_expected_date on the recurrence.",
141
+ name: "link_transaction_to_recurrence",
142
+ description: "Attach a single newly-seen transaction to an existing recurrence. Recomputes last_seen_date and next_expected_date on the recurrence.",
146
143
  input_schema: {
147
144
  type: "object",
148
145
  properties: {
149
- entry_id: { type: "string" },
146
+ transaction_id: { type: "string" },
150
147
  recurrence_id: { type: "string" },
151
148
  },
152
- required: ["entry_id", "recurrence_id"],
149
+ required: ["transaction_id", "recurrence_id"],
153
150
  },
154
151
  },
155
152
  {
@@ -163,53 +160,53 @@ const DEFS = [
163
160
  },
164
161
  ];
165
162
  const LABELS = {
166
- update_journal_entry: "Updating journal entry",
167
- update_journal_line: "Updating journal line",
168
- delete_journal_entry: "Deleting journal entry",
163
+ update_transaction: "Updating transaction",
164
+ update_posting: "Updating posting",
165
+ delete_transaction: "Deleting transaction",
169
166
  rename_account: "Renaming account",
170
167
  merge_accounts: "Merging accounts",
171
168
  delete_account: "Deleting account",
172
- find_duplicate_entries: "Finding duplicate entries",
169
+ find_duplicate_transactions: "Finding duplicate transactions",
173
170
  find_similar_accounts: "Finding similar accounts",
174
171
  find_unused_accounts: "Finding unused accounts",
175
- find_correlated_entries: "Finding correlated entries",
172
+ find_correlated_transactions: "Finding correlated transactions",
176
173
  find_recurrences: "Finding recurrences",
177
174
  record_recurrence: "Recording recurrence",
178
- link_entry_to_recurrence: "Linking entry to recurrence",
175
+ link_transaction_to_recurrence: "Linking transaction to recurrence",
179
176
  mark_review_done: "Finalizing review",
180
177
  };
181
178
  async function execute(db, name, input, ctx) {
182
179
  switch (name) {
183
- case "update_journal_entry": {
180
+ case "update_transaction": {
184
181
  if (ctx?.dryRun)
185
- return `Would update entry ${input.entry_id}: ${JSON.stringify(input)}`;
186
- const changed = updateJournalEntry(db, input.entry_id, {
182
+ return `Would update transaction ${input.transaction_id}: ${JSON.stringify(input)}`;
183
+ const changed = updateTransaction(db, input.transaction_id, {
187
184
  date: input.date,
188
185
  description: input.description,
189
186
  source_page: input.source_page,
190
187
  });
191
188
  return changed === 0
192
- ? `Entry ${input.entry_id} not found or no fields to update.`
193
- : `Updated entry ${input.entry_id}.`;
189
+ ? `Transaction ${input.transaction_id} not found or no fields to update.`
190
+ : `Updated transaction ${input.transaction_id}.`;
194
191
  }
195
- case "update_journal_line": {
192
+ case "update_posting": {
196
193
  if (ctx?.dryRun)
197
- return `Would update line ${input.line_id}: ${JSON.stringify(input)}`;
198
- const changed = updateJournalLine(db, input.line_id, {
194
+ return `Would update posting ${input.posting_id}: ${JSON.stringify(input)}`;
195
+ const changed = updatePosting(db, input.posting_id, {
199
196
  account_id: input.account_id,
200
197
  memo: input.memo,
201
198
  });
202
199
  return changed === 0
203
- ? `Line ${input.line_id} not found or no fields to update.`
204
- : `Updated line ${input.line_id}.`;
200
+ ? `Posting ${input.posting_id} not found or no fields to update.`
201
+ : `Updated posting ${input.posting_id}.`;
205
202
  }
206
- case "delete_journal_entry": {
203
+ case "delete_transaction": {
207
204
  if (ctx?.dryRun)
208
- return `Would delete entry ${input.entry_id} (and its lines).`;
209
- const changed = deleteJournalEntry(db, input.entry_id);
205
+ return `Would delete transaction ${input.transaction_id} (and its postings).`;
206
+ const changed = deleteTransaction(db, input.transaction_id);
210
207
  return changed === 0
211
- ? `Entry ${input.entry_id} not found.`
212
- : `Deleted entry ${input.entry_id} and its lines.`;
208
+ ? `Transaction ${input.transaction_id} not found.`
209
+ : `Deleted transaction ${input.transaction_id} and its postings.`;
213
210
  }
214
211
  case "rename_account": {
215
212
  if (ctx?.dryRun)
@@ -224,7 +221,7 @@ async function execute(db, name, input, ctx) {
224
221
  return `Would merge ${input.from_id} → ${input.to_id}.`;
225
222
  try {
226
223
  const moved = mergeAccounts(db, input.from_id, input.to_id);
227
- return `Merged ${input.from_id} → ${input.to_id}; moved ${moved} line(s).`;
224
+ return `Merged ${input.from_id} → ${input.to_id}; moved ${moved} posting(s).`;
228
225
  }
229
226
  catch (err) {
230
227
  return `Could not merge: ${err.message}`;
@@ -241,8 +238,8 @@ async function execute(db, name, input, ctx) {
241
238
  return `Could not delete: ${err.message}`;
242
239
  }
243
240
  }
244
- case "find_duplicate_entries": {
245
- const groups = findDuplicateEntries(db, {
241
+ case "find_duplicate_transactions": {
242
+ const groups = findDuplicateTransactions(db, {
246
243
  toleranceDays: input.tolerance_days,
247
244
  accountId: input.account_id,
248
245
  minAmount: input.min_amount,
@@ -251,11 +248,11 @@ async function execute(db, name, input, ctx) {
251
248
  return "No candidate duplicate groups found.";
252
249
  return groups
253
250
  .map((g, i) => {
254
- const header = `Group ${i + 1} — ${formatTHB(g[0].amount)}`;
251
+ const header = `Group ${i + 1} — ${formatAmount(g[0].amount)}`;
255
252
  const lines = g.map((e, j) => {
256
253
  const accounts = e.account_names.length > 0
257
254
  ? e.account_names.map(n => sanitizeForPrompt(n)).join(", ")
258
- : "(no lines)";
255
+ : "(no postings)";
259
256
  return ` ${j + 1}. ${e.date} "${sanitizeForPrompt(e.description)}" — ${accounts} [${e.id}]`;
260
257
  });
261
258
  return `${header}\n${lines.join("\n")}`;
@@ -276,25 +273,25 @@ async function execute(db, name, input, ctx) {
276
273
  return "No unused accounts.";
277
274
  return rows.map(a => `${a.id} | ${sanitizeForPrompt(a.name)} | ${a.type}`).join("\n");
278
275
  }
279
- case "find_correlated_entries": {
280
- const pairs = findCorrelatedEntries(db, {
276
+ case "find_correlated_transactions": {
277
+ const pairs = findCorrelatedTransactions(db, {
281
278
  from: input.from,
282
279
  to: input.to,
283
280
  toleranceDays: input.tolerance_days,
284
281
  minAmount: input.min_amount,
285
282
  });
286
283
  if (pairs.length === 0)
287
- return "No correlated entry pairs found.";
284
+ return "No correlated transaction pairs found.";
288
285
  return pairs
289
286
  .map((p, i) => {
290
287
  const accountsA = p.a.account_names.length > 0
291
288
  ? p.a.account_names.map(n => sanitizeForPrompt(n)).join(", ")
292
- : "(no lines)";
289
+ : "(no postings)";
293
290
  const accountsB = p.b.account_names.length > 0
294
291
  ? p.b.account_names.map(n => sanitizeForPrompt(n)).join(", ")
295
- : "(no lines)";
292
+ : "(no postings)";
296
293
  return [
297
- `Pair ${i + 1} — ${formatTHB(p.amount)} ${p.currency} (gap ${p.day_gap} day${p.day_gap === 1 ? "" : "s"})`,
294
+ `Pair ${i + 1} — ${formatAmount(p.amount)} ${p.currency} (gap ${p.day_gap} day${p.day_gap === 1 ? "" : "s"})`,
298
295
  ` A: ${p.a.date} "${sanitizeForPrompt(p.a.description)}" — ${accountsA} [${p.a.id}]`,
299
296
  ` B: ${p.b.date} "${sanitizeForPrompt(p.b.description)}" — ${accountsB} [${p.b.id}]`,
300
297
  ].join("\n");
@@ -310,20 +307,20 @@ async function execute(db, name, input, ctx) {
310
307
  return "No recurrence candidates found.";
311
308
  return candidates
312
309
  .map((c, i) => {
313
- const dates = c.entries.map(e => e.date).join(", ");
314
- const ids = c.entries.map(e => e.id).join(", ");
310
+ const dates = c.transactions.map(e => e.date).join(", ");
311
+ const ids = c.transactions.map(e => e.id).join(", ");
315
312
  return [
316
- `Candidate ${i + 1} — ${formatTHB(c.amount)} ${c.currency} on ${sanitizeForPrompt(c.account_name)} (${c.side})`,
317
- ` Sightings (${c.entries.length}): ${dates}`,
313
+ `Candidate ${i + 1} — ${formatAmount(c.amount)} ${c.currency} on ${sanitizeForPrompt(c.account_name)} (${c.side})`,
314
+ ` Sightings (${c.transactions.length}): ${dates}`,
318
315
  ` Median gap: ${c.median_days_between} day(s) → implied ${c.implied_frequency}`,
319
- ` Entry ids: ${ids}`,
316
+ ` Transaction ids: ${ids}`,
320
317
  ].join("\n");
321
318
  })
322
319
  .join("\n\n");
323
320
  }
324
321
  case "record_recurrence": {
325
322
  if (ctx?.dryRun)
326
- return `Would record ${input.frequency} recurrence "${input.description}" linking ${(input.entry_ids || []).length} entries on ${input.account_id}.`;
323
+ return `Would record ${input.frequency} recurrence "${input.description}" linking ${(input.transaction_ids || []).length} transactions on ${input.account_id}.`;
327
324
  try {
328
325
  const id = recordRecurrence(db, {
329
326
  account_id: input.account_id,
@@ -331,21 +328,21 @@ async function execute(db, name, input, ctx) {
331
328
  frequency: input.frequency,
332
329
  amount_typical: input.amount_typical ?? null,
333
330
  currency: input.currency,
334
- entry_ids: input.entry_ids || [],
331
+ transaction_ids: input.transaction_ids || [],
335
332
  notes: input.notes ?? null,
336
333
  });
337
- return `Recorded recurrence ${id} ("${sanitizeForPrompt(input.description)}", ${input.frequency}); linked ${(input.entry_ids || []).length} entry(ies).`;
334
+ return `Recorded recurrence ${id} ("${sanitizeForPrompt(input.description)}", ${input.frequency}); linked ${(input.transaction_ids || []).length} transaction(s).`;
338
335
  }
339
336
  catch (err) {
340
337
  return `Could not record recurrence: ${err.message}`;
341
338
  }
342
339
  }
343
- case "link_entry_to_recurrence": {
340
+ case "link_transaction_to_recurrence": {
344
341
  if (ctx?.dryRun)
345
- return `Would link entry ${input.entry_id} → recurrence ${input.recurrence_id}.`;
342
+ return `Would link transaction ${input.transaction_id} → recurrence ${input.recurrence_id}.`;
346
343
  try {
347
- linkEntryToRecurrence(db, input.entry_id, input.recurrence_id);
348
- return `Linked entry ${input.entry_id} → recurrence ${input.recurrence_id}.`;
344
+ linkTransactionToRecurrence(db, input.transaction_id, input.recurrence_id);
345
+ return `Linked transaction ${input.transaction_id} → recurrence ${input.recurrence_id}.`;
349
346
  }
350
347
  catch (err) {
351
348
  return `Could not link: ${err.message}`;
@@ -2,7 +2,7 @@ import { sanitizeForPrompt } from "../sanitize.js";
2
2
  const DEFS = [
3
3
  {
4
4
  name: "mark_file_scanned",
5
- description: "Call this once the file is fully processed and all journal entries are posted. Summary text is shown to the user.",
5
+ description: "Call this once the file is fully processed and all transactions are posted. Summary text is shown to the user.",
6
6
  input_schema: {
7
7
  type: "object",
8
8
  properties: {
@@ -1,7 +1,7 @@
1
1
  import type Database from "libsql";
2
2
  import type { ToolDefinition } from "../provider.js";
3
3
  import type { BufferedWriteContext } from "../../scanner/buffer.js";
4
- export type ToolProfile = "scan" | "chat" | "review";
4
+ export type ToolProfile = "scan" | "chat" | "review" | "record";
5
5
  /**
6
6
  * Structured highlights the review agent can pass to ask_user. The prompter
7
7
  * renders them as a single colored header line above the question (each
@@ -15,7 +15,7 @@ export interface PromptUserFacts {
15
15
  accounts?: string[];
16
16
  }
17
17
  export interface AgentExecutionContext {
18
- /** Set during scan so `record_journal_entry` can stamp `source_file_id`. */
18
+ /** Set during scan so `record_transaction` can stamp `source_file_id`. */
19
19
  fileId?: string;
20
20
  /** When false, ask_user returns a marker and the caller halts after the run. */
21
21
  interactive: boolean;
@@ -26,10 +26,19 @@ export interface AgentExecutionContext {
26
26
  /** Called when the model declares the session is done (scan or review). */
27
27
  onComplete?: (summary: string) => void;
28
28
  /**
29
- * Scan-only: when set, journal entries and concerns are queued here instead
30
- * of being written directly to the DB. Account writes still hit the DB
31
- * eagerly (serialized via account_mutex) so concurrent scan agents share
32
- * the same chart of accounts.
29
+ * Which top-level command this agent serves. Mutating tools branch on this
30
+ * to decide whether to append an action_log row (currently only "record").
31
+ */
32
+ command?: "scan" | "review" | "record";
33
+ /** Per-invocation id grouping every action_log row from one CLI run. */
34
+ correlationId?: string;
35
+ /** The raw user utterance / file path that started this invocation. */
36
+ userInput?: string;
37
+ /**
38
+ * Scan-only: when set, transactions and concerns are queued here instead of
39
+ * being written directly to the DB. Account and merchant writes still hit
40
+ * the DB eagerly (serialized via their own mutexes) so concurrent scan
41
+ * agents share the same chart of accounts and merchant directory.
33
42
  */
34
43
  buffer?: BufferedWriteContext;
35
44
  }
@@ -1,19 +1,8 @@
1
1
  import chalk from "chalk";
2
2
  import { getDb } from "../../db/connection.js";
3
3
  import { getAccountBalances } from "../../db/queries/account_balance.js";
4
- import { formatCurrencyAmount } from "../../currency.js";
5
- function fmtSigned(n) {
6
- const body = formatCurrencyAmount(n, {
7
- minimumFractionDigits: 2,
8
- maximumFractionDigits: 2,
9
- });
10
- return n < 0 ? `-${body}` : body;
11
- }
12
- // eslint-disable-next-line no-control-regex
13
- const ANSI_RE = /\x1b\[[0-9;]*m/g;
14
- function visibleLength(s) {
15
- return s.replace(ANSI_RE, "").length;
16
- }
4
+ import { visibleLength } from "../format.js";
5
+ import { formatSignedAmount } from "../../currency.js";
17
6
  const TYPE_TAG = {
18
7
  asset: "asset",
19
8
  liability: "liab",
@@ -48,20 +37,39 @@ function compactMeta(a) {
48
37
  }
49
38
  export function showAccounts() {
50
39
  const db = getDb();
51
- const accounts = [...getAccountBalances(db)].sort((a, b) => {
52
- const t = TYPE_RANK[a.type] - TYPE_RANK[b.type];
53
- return t !== 0 ? t : a.name.localeCompare(b.name);
54
- });
55
- if (accounts.length === 0) {
40
+ const raw = getAccountBalances(db);
41
+ if (raw.length === 0) {
56
42
  console.log(chalk.yellow("No accounts yet. Drop your bank/credit card statements into ~/.plasalid/data/ and run `plasalid scan`."));
57
43
  return;
58
44
  }
59
- const balanceWidth = Math.max(...accounts.map((a) => fmtSigned(a.balance).length));
60
- const nameWidth = Math.max(...accounts.map((a) => a.name.length));
45
+ const byId = new Map(raw.map(a => [a.id, a]));
46
+ const depthCache = new Map();
47
+ const depthOf = (id) => {
48
+ if (depthCache.has(id))
49
+ return depthCache.get(id);
50
+ const node = byId.get(id);
51
+ if (!node || !node.parent_id) {
52
+ depthCache.set(id, 0);
53
+ return 0;
54
+ }
55
+ const d = depthOf(node.parent_id) + 1;
56
+ depthCache.set(id, d);
57
+ return d;
58
+ };
59
+ const accounts = [...raw].sort((a, b) => {
60
+ const t = TYPE_RANK[a.type] - TYPE_RANK[b.type];
61
+ if (t !== 0)
62
+ return t;
63
+ return a.id.localeCompare(b.id);
64
+ });
65
+ const balanceWidth = Math.max(...accounts.map((a) => formatSignedAmount(a.balance).length));
66
+ const nameWidth = Math.max(...accounts.map((a) => a.name.length + depthOf(a.id) * 2));
61
67
  for (const a of accounts) {
62
68
  const tag = chalk.dim(TYPE_TAG[a.type].padEnd(TYPE_TAG_WIDTH));
63
- const name = chalk.bold(a.name) + " ".repeat(nameWidth - a.name.length);
64
- const rawBalance = fmtSigned(a.balance);
69
+ const indent = " ".repeat(depthOf(a.id));
70
+ const displayName = indent + a.name;
71
+ const name = chalk.bold(displayName) + " ".repeat(nameWidth - displayName.length);
72
+ const rawBalance = formatSignedAmount(a.balance);
65
73
  const coloredBalance = a.balance < 0 ? chalk.red(rawBalance) : rawBalance;
66
74
  const paddedBalance = " ".repeat(balanceWidth - visibleLength(coloredBalance)) + coloredBalance;
67
75
  const meta = compactMeta(a);
@@ -78,9 +86,9 @@ export function showAccounts() {
78
86
  const netWorth = assets - liabilities;
79
87
  console.log("");
80
88
  console.log(" " +
81
- chalk.dim(`Assets ${fmtSigned(assets)}`) +
89
+ chalk.dim(`Assets ${formatSignedAmount(assets)}`) +
82
90
  chalk.dim(" · ") +
83
- chalk.dim(`Liabilities ${fmtSigned(liabilities)}`) +
91
+ chalk.dim(`Liabilities ${formatSignedAmount(liabilities)}`) +
84
92
  chalk.dim(" · ") +
85
- chalk.bold(`Net worth ${fmtSigned(netWorth)}`));
93
+ chalk.bold(`Net worth ${formatSignedAmount(netWorth)}`));
86
94
  }
@@ -0,0 +1,4 @@
1
+ export interface RecordCommandOptions {
2
+ utterance: string;
3
+ }
4
+ export declare function runRecordCommand(opts: RecordCommandOptions): Promise<void>;
@@ -0,0 +1,119 @@
1
+ import chalk from "chalk";
2
+ import { randomUUID } from "crypto";
3
+ import { getDb } from "../../db/connection.js";
4
+ import { runRecordAgent } from "../../ai/agent.js";
5
+ import { makePromptUser, makeAgentOnProgress, statusSpinner } from "../ux.js";
6
+ import { listActions } from "../../db/queries/action_log.js";
7
+ import { formatAmount } from "../../currency.js";
8
+ export async function runRecordCommand(opts) {
9
+ const utterance = opts.utterance.trim();
10
+ if (!utterance) {
11
+ console.error(chalk.red(`Usage: plasalid record "<what happened>"`));
12
+ process.exitCode = 1;
13
+ return;
14
+ }
15
+ const db = getDb();
16
+ const spinner = statusSpinner("Thinking...");
17
+ const promptUser = makePromptUser(spinner);
18
+ const onProgress = makeAgentOnProgress(spinner);
19
+ const correlationId = `cr:${randomUUID()}`;
20
+ const initialMessages = [
21
+ { role: "user", content: utterance },
22
+ ];
23
+ try {
24
+ const text = await runRecordAgent({
25
+ db,
26
+ initialMessages,
27
+ prompt: { utterance },
28
+ agentCtx: {
29
+ command: "record",
30
+ correlationId,
31
+ userInput: utterance,
32
+ interactive: !!process.stdout.isTTY,
33
+ promptUser,
34
+ },
35
+ onProgress,
36
+ });
37
+ spinner.succeed("Done.");
38
+ if (text) {
39
+ console.log("");
40
+ console.log(text);
41
+ }
42
+ renderActionSummary(correlationId);
43
+ }
44
+ catch (err) {
45
+ spinner.fail(err?.message ?? "Record failed.");
46
+ process.exitCode = 1;
47
+ }
48
+ }
49
+ function renderActionSummary(correlationId) {
50
+ const actions = listActions(getDb(), { correlationId });
51
+ if (actions.length === 0)
52
+ return;
53
+ console.log("");
54
+ console.log(chalk.dim(`Logged ${actions.length} action${actions.length === 1 ? "" : "s"} (${correlationId}):`));
55
+ for (const a of actions) {
56
+ console.log(chalk.dim(` · ${describeAction(a)}`));
57
+ }
58
+ }
59
+ function describeAction(a) {
60
+ const payload = safeJson(a.payload_json);
61
+ switch (a.action_type) {
62
+ case "create_account": {
63
+ const name = payload?.row?.name ? ` — ${payload.row.name}` : "";
64
+ return `create_account ${a.target_id}${name}`;
65
+ }
66
+ case "update_account_metadata": {
67
+ const fields = payload?.after && typeof payload.after === "object"
68
+ ? Object.keys(payload.after).join(", ")
69
+ : "";
70
+ return `update_account_metadata ${a.target_id}${fields ? ` — ${fields}` : ""}`;
71
+ }
72
+ case "record_transaction": {
73
+ const date = payload?.transaction?.date ?? "";
74
+ const desc = payload?.transaction?.description ?? "";
75
+ const total = totalDebit(payload?.postings);
76
+ const amount = total != null ? ` ${formatTotal(total, currencyOf(payload?.postings))}` : "";
77
+ return `record_transaction ${a.target_id} — ${[date, desc].filter(Boolean).join(" ")}${amount}`;
78
+ }
79
+ case "adjust_balance": {
80
+ const before = payload?.before_balance;
81
+ const after = payload?.after_balance;
82
+ const currency = currencyOf(payload?.postings);
83
+ if (typeof before === "number" && typeof after === "number") {
84
+ return `adjust_balance ${payload?.account_id ?? a.target_id} — ${formatTotal(before, currency)} → ${formatTotal(after, currency)}`;
85
+ }
86
+ return `adjust_balance ${a.target_id}`;
87
+ }
88
+ case "create_merchant": {
89
+ const name = payload?.canonical_name ?? "";
90
+ return `create_merchant ${a.target_id}${name ? ` — ${name}` : ""}`;
91
+ }
92
+ case "update_merchant_default": {
93
+ return `update_merchant_default ${a.target_id} — ${payload?.before ?? "(none)"} → ${payload?.after ?? "(none)"}`;
94
+ }
95
+ default:
96
+ return `${a.action_type} ${a.target_id}`;
97
+ }
98
+ }
99
+ function safeJson(s) {
100
+ try {
101
+ return JSON.parse(s);
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ function totalDebit(postings) {
108
+ if (!Array.isArray(postings))
109
+ return null;
110
+ return postings.reduce((sum, p) => sum + (Number(p?.debit) || 0), 0);
111
+ }
112
+ function currencyOf(postings) {
113
+ if (Array.isArray(postings) && postings[0]?.currency)
114
+ return String(postings[0].currency);
115
+ return "THB";
116
+ }
117
+ function formatTotal(amount, currency) {
118
+ return formatAmount(amount, currency);
119
+ }
@@ -51,7 +51,7 @@ export async function runRevertCommand(regex) {
51
51
  console.log(chalk.dim("No scanned files match that regex."));
52
52
  return;
53
53
  }
54
- console.log(chalk.bold(`revert will delete ${matches.length} file(s) and their journal entries:`));
54
+ console.log(chalk.bold(`revert will delete ${matches.length} file(s) and their transactions:`));
55
55
  for (const m of matches) {
56
56
  const when = m.scannedAt ? chalk.dim(` (scanned ${m.scannedAt})`) : "";
57
57
  console.log(` • ${m.relPath}${when}`);