plasalid 0.4.1 → 0.5.1

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 (66) 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/hooks/useFooterText.js +2 -1
  36. package/dist/cli/ink/scan_dashboard.d.ts +1 -1
  37. package/dist/cli/ink/scan_dashboard.js +2 -2
  38. package/dist/cli/setup.d.ts +0 -1
  39. package/dist/cli/setup.js +2 -8
  40. package/dist/currency.d.ts +3 -0
  41. package/dist/currency.js +12 -1
  42. package/dist/db/queries/account_balance.d.ts +83 -4
  43. package/dist/db/queries/account_balance.js +239 -20
  44. package/dist/db/queries/action_log.d.ts +29 -0
  45. package/dist/db/queries/action_log.js +27 -0
  46. package/dist/db/queries/concerns.d.ts +10 -7
  47. package/dist/db/queries/concerns.js +20 -16
  48. package/dist/db/queries/journal.d.ts +1 -0
  49. package/dist/db/queries/merchants.d.ts +42 -0
  50. package/dist/db/queries/merchants.js +120 -0
  51. package/dist/db/queries/recurrences.d.ts +3 -3
  52. package/dist/db/queries/recurrences.js +32 -34
  53. package/dist/db/queries/search.d.ts +5 -4
  54. package/dist/db/queries/search.js +16 -12
  55. package/dist/db/queries/transactions.d.ts +167 -0
  56. package/dist/db/queries/transactions.js +320 -0
  57. package/dist/db/schema.js +51 -9
  58. package/dist/reviewer/pipeline.d.ts +4 -4
  59. package/dist/reviewer/pipeline.js +4 -4
  60. package/dist/reviewer/prompts.js +4 -4
  61. package/dist/scanner/buffer.d.ts +24 -21
  62. package/dist/scanner/buffer.js +18 -18
  63. package/dist/scanner/pipeline.d.ts +3 -2
  64. package/dist/scanner/pipeline.js +33 -36
  65. package/dist/scanner/prompts.js +3 -3
  66. package/package.json +2 -2
@@ -0,0 +1,117 @@
1
+ import { upsertMerchant, findMerchantByAlias, findMerchantById, setMerchantDefaultAccount, } from "../../db/queries/merchants.js";
2
+ import { appendAction } from "../../db/queries/action_log.js";
3
+ import { sanitizeForPrompt } from "../sanitize.js";
4
+ /**
5
+ * Merchant tools
6
+ *
7
+ * Used by record-mode for utterances about merchants ("set Starbucks default
8
+ * to Dining") and by the scanner pipeline for pre-resolution lookups. The
9
+ * scan path normally resolves merchants inline via `record_transaction`'s
10
+ * embedded `merchant` block; these standalone tools exist for the cases where
11
+ * the LLM needs to query or update merchants without posting a transaction.
12
+ */
13
+ const DEFS = [
14
+ {
15
+ name: "find_or_create_merchant",
16
+ description: "Upsert a merchant by canonical_name. Optionally register a raw-descriptor alias and a learned default expense account. Returns the merchant row. Use this in record mode for utterances like 'add Spotify as a subscription merchant' or 'mark Starbucks as Dining'.",
17
+ input_schema: {
18
+ type: "object",
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." },
23
+ },
24
+ required: ["canonical_name"],
25
+ },
26
+ },
27
+ {
28
+ name: "find_merchant_by_descriptor",
29
+ description: "Look up an existing merchant by its raw descriptor (alias match after normalization). Returns null if no alias matches.",
30
+ input_schema: {
31
+ type: "object",
32
+ properties: {
33
+ descriptor: { type: "string", description: "The raw statement line or merchant string to look up." },
34
+ },
35
+ required: ["descriptor"],
36
+ },
37
+ },
38
+ {
39
+ name: "set_merchant_default_account",
40
+ description: "Update a merchant's learned default expense account. Use after the user (or you) recategorizes a posting so future statements skip the LLM categorizer.",
41
+ input_schema: {
42
+ type: "object",
43
+ properties: {
44
+ merchant_id: { type: "string" },
45
+ account_id: { type: "string" },
46
+ },
47
+ required: ["merchant_id", "account_id"],
48
+ },
49
+ },
50
+ ];
51
+ const LABELS = {
52
+ find_or_create_merchant: "Resolving merchant",
53
+ find_merchant_by_descriptor: "Looking up merchant",
54
+ set_merchant_default_account: "Updating merchant default",
55
+ };
56
+ async function execute(db, name, input, ctx) {
57
+ switch (name) {
58
+ case "find_or_create_merchant": {
59
+ if (ctx?.dryRun)
60
+ return `Would upsert merchant "${input.canonical_name}".`;
61
+ const existing = db
62
+ .prepare(`SELECT id FROM merchants WHERE canonical_name = ?`)
63
+ .get(input.canonical_name);
64
+ const merchant = upsertMerchant(db, {
65
+ canonical_name: input.canonical_name,
66
+ alias: input.alias,
67
+ default_account_id: input.default_account_id,
68
+ });
69
+ if (ctx?.correlationId && !existing) {
70
+ appendAction(db, {
71
+ correlation_id: ctx.correlationId,
72
+ command: ctx.command ?? "record",
73
+ user_input: ctx.userInput ?? null,
74
+ action_type: "create_merchant",
75
+ target_id: merchant.id,
76
+ payload: { canonical_name: merchant.canonical_name, default_account_id: merchant.default_account_id },
77
+ });
78
+ }
79
+ const defaultStr = merchant.default_account_id ? ` (default → ${merchant.default_account_id})` : "";
80
+ return `Merchant ${merchant.id}: ${sanitizeForPrompt(merchant.canonical_name)}${defaultStr}.`;
81
+ }
82
+ case "find_merchant_by_descriptor": {
83
+ const hit = findMerchantByAlias(db, String(input.descriptor ?? ""));
84
+ if (!hit)
85
+ return `No merchant matched descriptor "${sanitizeForPrompt(String(input.descriptor ?? ""))}".`;
86
+ const defaultStr = hit.default_account_id ? ` (default → ${hit.default_account_id})` : "";
87
+ return `Merchant ${hit.merchant.id}: ${sanitizeForPrompt(hit.merchant.canonical_name)}${defaultStr}.`;
88
+ }
89
+ case "set_merchant_default_account": {
90
+ if (ctx?.dryRun)
91
+ return `Would set ${input.merchant_id}'s default to ${input.account_id}.`;
92
+ const m = findMerchantById(db, input.merchant_id);
93
+ if (!m)
94
+ return `Merchant ${input.merchant_id} not found.`;
95
+ try {
96
+ const result = setMerchantDefaultAccount(db, input.merchant_id, input.account_id);
97
+ if (ctx?.correlationId) {
98
+ appendAction(db, {
99
+ correlation_id: ctx.correlationId,
100
+ command: ctx.command ?? "record",
101
+ user_input: ctx.userInput ?? null,
102
+ action_type: "update_merchant_default",
103
+ target_id: input.merchant_id,
104
+ payload: { before: result.before, after: result.after },
105
+ });
106
+ }
107
+ return `Merchant ${input.merchant_id}: default ${result.before ?? "(none)"} → ${result.after}.`;
108
+ }
109
+ catch (err) {
110
+ return `Could not set merchant default: ${err.message}`;
111
+ }
112
+ }
113
+ default:
114
+ return undefined;
115
+ }
116
+ }
117
+ export const merchantTools = { DEFS, LABELS, execute };
@@ -1,12 +1,9 @@
1
1
  import { findAccountById, getAccountBalances, getNetWorth, getPeriodTotals, } from "../../db/queries/account_balance.js";
2
- import { listJournalLines } from "../../db/queries/journal.js";
2
+ import { listPostings } from "../../db/queries/transactions.js";
3
3
  import { listOpenConcerns } from "../../db/queries/concerns.js";
4
- import { searchJournalLines } from "../../db/queries/search.js";
5
- import { formatCurrencyAmount } from "../../currency.js";
4
+ import { searchPostings } from "../../db/queries/search.js";
5
+ import { formatAmount } from "../../currency.js";
6
6
  import { sanitizeForPrompt, sanitizeForPromptCell } from "../sanitize.js";
7
- function formatTHB(amount) {
8
- return formatCurrencyAmount(amount, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
9
- }
10
7
  const DEFS = [
11
8
  {
12
9
  name: "get_account_balance",
@@ -23,15 +20,15 @@ const DEFS = [
23
20
  input_schema: { type: "object", properties: {}, required: [] },
24
21
  },
25
22
  {
26
- name: "list_journal_entries",
27
- description: "List journal lines filtered by account and/or date range.",
23
+ name: "list_postings",
24
+ description: "List transaction postings filtered by account and/or date range.",
28
25
  input_schema: {
29
26
  type: "object",
30
27
  properties: {
31
28
  account_id: { type: "string" },
32
29
  from: { type: "string", description: "Start date YYYY-MM-DD" },
33
30
  to: { type: "string", description: "End date YYYY-MM-DD" },
34
- q: { type: "string", description: "Free-text contains-match on description or memo" },
31
+ q: { type: "string", description: "Free-text contains-match on description, memo, or merchant" },
35
32
  limit: { type: "number", description: "Max results (default 50)" },
36
33
  },
37
34
  required: [],
@@ -39,7 +36,7 @@ const DEFS = [
39
36
  },
40
37
  {
41
38
  name: "search_transactions",
42
- description: "Free-text search across journal entry descriptions, line memos, and account names.",
39
+ description: "Free-text search across transaction descriptions, posting memos, account names, and merchant names.",
43
40
  input_schema: {
44
41
  type: "object",
45
42
  properties: {
@@ -63,11 +60,12 @@ const DEFS = [
63
60
  },
64
61
  {
65
62
  name: "list_open_concerns",
66
- description: "List clarification requests recorded by the scanner that have not been resolved yet. Each row carries the prompt, optional candidate answers, and the file/entry/account it was attached to. The reviewer uses this to drive the step-by-step clarification loop.",
63
+ description: "List clarification requests recorded by the scanner that have not been resolved yet. Each row carries the prompt, optional candidate answers, and the file/transaction/account it was attached to. The reviewer uses this to drive the step-by-step clarification loop.",
67
64
  input_schema: {
68
65
  type: "object",
69
66
  properties: {
70
67
  limit: { type: "number", default: 50 },
68
+ kind: { type: "string", description: "Optional filter by concern kind (e.g. 'uncategorized_expense')." },
71
69
  },
72
70
  required: [],
73
71
  },
@@ -76,7 +74,7 @@ const DEFS = [
76
74
  const LABELS = {
77
75
  get_account_balance: "Looking up balance",
78
76
  get_net_worth: "Computing net worth",
79
- list_journal_entries: "Listing journal entries",
77
+ list_postings: "Listing postings",
80
78
  search_transactions: "Searching transactions",
81
79
  get_period_totals: "Summing period totals",
82
80
  list_open_concerns: "Listing open concerns",
@@ -89,14 +87,14 @@ async function execute(db, name, input, _ctx) {
89
87
  return `Account "${input.account_id}" not found.`;
90
88
  const balances = getAccountBalances(db);
91
89
  const bal = balances.find(b => b.id === acct.id)?.balance ?? 0;
92
- return `${sanitizeForPrompt(acct.name)} (${acct.type}): ${formatTHB(bal)}`;
90
+ return `${sanitizeForPrompt(acct.name)} (${acct.type}): ${formatAmount(bal)}`;
93
91
  }
94
92
  case "get_net_worth": {
95
93
  const nw = getNetWorth(db);
96
- return `Net worth: ${formatTHB(nw.net_worth)} (assets ${formatTHB(nw.assets)} − liabilities ${formatTHB(nw.liabilities)})`;
94
+ return `Net worth: ${formatAmount(nw.net_worth)} (assets ${formatAmount(nw.assets)} − liabilities ${formatAmount(nw.liabilities)})`;
97
95
  }
98
- case "list_journal_entries": {
99
- const rows = listJournalLines(db, {
96
+ case "list_postings": {
97
+ const rows = listPostings(db, {
100
98
  account_id: input.account_id,
101
99
  from: input.from,
102
100
  to: input.to,
@@ -104,41 +102,45 @@ async function execute(db, name, input, _ctx) {
104
102
  limit: input.limit,
105
103
  });
106
104
  if (rows.length === 0)
107
- return "No matching journal lines.";
105
+ return "No matching postings.";
108
106
  return rows
109
107
  .map(r => {
110
- const dr = r.debit > 0 ? `DR ${formatTHB(r.debit)}` : "";
111
- const cr = r.credit > 0 ? `CR ${formatTHB(r.credit)}` : "";
112
- return `${r.entry_date} | ${sanitizeForPromptCell(r.entry_description)} | ${sanitizeForPromptCell(r.account_name || "")} | ${dr}${cr} | ${sanitizeForPromptCell(r.memo || "")}`;
108
+ const dr = r.debit > 0 ? `DR ${formatAmount(r.debit)}` : "";
109
+ const cr = r.credit > 0 ? `CR ${formatAmount(r.credit)}` : "";
110
+ const merchant = r.merchant_name ? ` (${sanitizeForPromptCell(r.merchant_name)})` : "";
111
+ return `${r.transaction_date} | ${sanitizeForPromptCell(r.transaction_description || "")}${merchant} | ${sanitizeForPromptCell(r.account_name || "")} | ${dr}${cr} | ${sanitizeForPromptCell(r.memo || "")}`;
113
112
  })
114
113
  .join("\n");
115
114
  }
116
115
  case "search_transactions": {
117
- const rows = searchJournalLines(db, input.query, input.limit);
116
+ const rows = searchPostings(db, input.query, input.limit);
118
117
  if (rows.length === 0)
119
118
  return `No matches for "${sanitizeForPrompt(input.query)}".`;
120
119
  return rows
121
120
  .map(r => {
122
- const dr = r.debit > 0 ? `DR ${formatTHB(r.debit)}` : "";
123
- const cr = r.credit > 0 ? `CR ${formatTHB(r.credit)}` : "";
124
- return `${r.entry_date} | ${sanitizeForPromptCell(r.entry_description)} | ${sanitizeForPromptCell(r.account_name || "")} | ${dr}${cr}`;
121
+ const dr = r.debit > 0 ? `DR ${formatAmount(r.debit)}` : "";
122
+ const cr = r.credit > 0 ? `CR ${formatAmount(r.credit)}` : "";
123
+ const merchant = r.merchant_name ? ` (${sanitizeForPromptCell(r.merchant_name)})` : "";
124
+ return `${r.transaction_date} | ${sanitizeForPromptCell(r.transaction_description || "")}${merchant} | ${sanitizeForPromptCell(r.account_name || "")} | ${dr}${cr}`;
125
125
  })
126
126
  .join("\n");
127
127
  }
128
128
  case "get_period_totals": {
129
129
  const totals = getPeriodTotals(db, input.from, input.to);
130
- return `Income ${formatTHB(totals.income)} · Expenses ${formatTHB(totals.expenses)} · Net ${formatTHB(totals.income - totals.expenses)}`;
130
+ return `Income ${formatAmount(totals.income)} · Expenses ${formatAmount(totals.expenses)} · Net ${formatAmount(totals.income - totals.expenses)}`;
131
131
  }
132
132
  case "list_open_concerns": {
133
133
  const rows = listOpenConcerns(db, input.limit ?? 50);
134
- if (rows.length === 0)
134
+ const filtered = input.kind ? rows.filter(r => r.kind === input.kind) : rows;
135
+ if (filtered.length === 0)
135
136
  return "No open concerns. The picture is clear.";
136
- return rows
137
+ return filtered
137
138
  .map(r => {
138
139
  const targets = [
139
- r.entry_id ? `entry=${r.entry_id}` : null,
140
+ r.transaction_id ? `transaction=${r.transaction_id}` : null,
140
141
  r.account_id ? `account=${r.account_id}` : null,
141
- !r.entry_id && !r.account_id && r.file_id ? `file=${r.file_id}` : null,
142
+ !r.transaction_id && !r.account_id && r.file_id ? `file=${r.file_id}` : null,
143
+ r.kind ? `kind=${r.kind}` : null,
142
144
  ].filter(Boolean).join(" ");
143
145
  const options = r.options_json
144
146
  ? ` [options: ${JSON.parse(r.options_json).map(o => sanitizeForPrompt(o)).join(" | ")}]`
@@ -0,0 +1,2 @@
1
+ import type { ToolModule } from "./types.js";
2
+ export declare const recordTools: ToolModule;
@@ -0,0 +1,188 @@
1
+ import { findAccountById, findAccountsByFuzzyName, getAccountBalances, ensureStructuralAccount, } from "../../db/queries/account_balance.js";
2
+ import { validateTransaction, insertTransactionRows, } from "../../db/queries/transactions.js";
3
+ import { appendAction } from "../../db/queries/action_log.js";
4
+ import { formatAmount } from "../../currency.js";
5
+ import { sanitizeForPrompt } from "../sanitize.js";
6
+ const EQUITY_ADJUST_ID = "equity:adjustments";
7
+ function todayIso() {
8
+ return new Date().toISOString().slice(0, 10);
9
+ }
10
+ /**
11
+ * Record-only tool definitions
12
+ *
13
+ * `find_similar_accounts` and `clarify` are reads / prompts; only
14
+ * `adjust_account_balance` mutates the DB. It writes its own action_log row
15
+ * with `action_type='adjust_balance'` rather than going through the shared
16
+ * `record_transaction` path.
17
+ */
18
+ const DEFS = [
19
+ {
20
+ name: "find_similar_accounts",
21
+ description: "Find existing accounts whose name fuzzy-matches a candidate. Always call this before create_account when the user names an account (e.g. 'my ttb saving', 'SET portfolio') so you don't create a duplicate. Returns the top matches with similarity scores; if the highest score is >= 0.7 and it isn't an exact id hit, call clarify to confirm with the user before creating a new one.",
22
+ input_schema: {
23
+ type: "object",
24
+ properties: {
25
+ query: { type: "string", description: "Free-text name to match against the chart of accounts." },
26
+ threshold: { type: "number", description: "Minimum similarity (0-1). Default 0.5.", default: 0.5 },
27
+ },
28
+ required: ["query"],
29
+ },
30
+ },
31
+ {
32
+ name: "adjust_account_balance",
33
+ description: "Move an account's current balance to `target_balance` by posting a balancing transaction against the equity:adjustments account. Use this when the user states a balance ('my SET portfolio is now 1.8MB', '500k networth in my Diem Investment') rather than a transaction. Reads the account's current balance, computes the delta, posts a 2-posting transaction with the right debit/credit sides for the account type, and creates equity:adjustments on demand if it doesn't exist. Currency follows the account.",
34
+ input_schema: {
35
+ type: "object",
36
+ properties: {
37
+ account_id: { type: "string" },
38
+ target_balance: { type: "number", description: "The new desired balance in the account's currency, in natural sign (positive)." },
39
+ reason: { type: "string", description: "Short description, e.g. 'Set DIEM portfolio to current market value (user-asserted).'." },
40
+ date: { type: "string", description: "ISO YYYY-MM-DD. Defaults to today." },
41
+ },
42
+ required: ["account_id", "target_balance", "reason"],
43
+ },
44
+ },
45
+ {
46
+ name: "clarify",
47
+ description: "Ask the user a clarifying question and return their answer as a string. Use when the utterance is ambiguous (multiple matching accounts, missing amount, unclear date, can't tell expense vs transfer, plan confirmation before a multi-step action). Unlike review's ask_user, this does NOT write to the concerns table — record-time questions are transient.",
48
+ input_schema: {
49
+ type: "object",
50
+ properties: {
51
+ prompt: { type: "string", description: "The question to ask in plain language." },
52
+ options: {
53
+ type: "array",
54
+ items: { type: "string" },
55
+ description: "Optional list of candidate answers.",
56
+ },
57
+ facts: {
58
+ type: "object",
59
+ description: "Optional structured highlights rendered as a single colored header line above the question. amount=yellow, date=cyan, merchant=green, accounts=magenta.",
60
+ properties: {
61
+ amount: { type: "string" },
62
+ date: { type: "string" },
63
+ merchant: { type: "string" },
64
+ accounts: { type: "array", items: { type: "string" } },
65
+ },
66
+ },
67
+ },
68
+ required: ["prompt"],
69
+ },
70
+ },
71
+ ];
72
+ const LABELS = {
73
+ find_similar_accounts: "Searching similar accounts",
74
+ adjust_account_balance: "Adjusting balance",
75
+ clarify: "Asking for clarification",
76
+ };
77
+ async function execute(db, name, input, ctx) {
78
+ switch (name) {
79
+ case "find_similar_accounts": {
80
+ const matches = findAccountsByFuzzyName(db, String(input.query ?? ""), input.threshold);
81
+ if (matches.length === 0)
82
+ return `No accounts matched "${sanitizeForPrompt(input.query ?? "")}".`;
83
+ return matches
84
+ .slice(0, 8)
85
+ .map(m => `${m.account.id} | ${sanitizeForPrompt(m.account.name)} | ${m.account.type}${m.account.subtype ? `/${m.account.subtype}` : ""} | similarity ${m.similarity}`)
86
+ .join("\n");
87
+ }
88
+ case "adjust_account_balance":
89
+ return adjustAccountBalance(db, input, ctx);
90
+ case "clarify": {
91
+ if (!ctx)
92
+ return "clarify is only available inside an agent session.";
93
+ if (!ctx.interactive || !ctx.promptUser) {
94
+ return `Awaiting user input — cannot proceed in non-interactive mode. Question was: ${sanitizeForPrompt(input.prompt)}`;
95
+ }
96
+ const answer = await ctx.promptUser(input.prompt, input.options, input.facts);
97
+ return `User answered: ${sanitizeForPrompt(answer)}`;
98
+ }
99
+ default:
100
+ return undefined;
101
+ }
102
+ }
103
+ async function adjustAccountBalance(db, input, ctx) {
104
+ if (!ctx)
105
+ return "adjust_account_balance is only available inside an agent session.";
106
+ if (ctx.dryRun)
107
+ return `Would adjust ${input.account_id} balance to ${input.target_balance}.`;
108
+ const account = findAccountById(db, input.account_id);
109
+ if (!account)
110
+ return `Account "${input.account_id}" not found.`;
111
+ const target = Number(input.target_balance);
112
+ if (!Number.isFinite(target))
113
+ return `target_balance must be a number, got ${JSON.stringify(input.target_balance)}.`;
114
+ const balances = getAccountBalances(db);
115
+ const current = balances.find(b => b.id === account.id)?.balance ?? 0;
116
+ const delta = round2(target - current);
117
+ if (delta === 0) {
118
+ return `${sanitizeForPrompt(account.name)} is already at ${formatAmount(target)}; no transaction posted.`;
119
+ }
120
+ const amount = Math.abs(delta);
121
+ const debitNormal = account.type === "asset" || account.type === "expense";
122
+ const debitAccountId = (debitNormal && delta > 0) || (!debitNormal && delta < 0)
123
+ ? account.id
124
+ : EQUITY_ADJUST_ID;
125
+ const creditAccountId = debitAccountId === account.id ? EQUITY_ADJUST_ID : account.id;
126
+ const date = input.date && /^\d{4}-\d{2}-\d{2}$/.test(input.date) ? input.date : todayIso();
127
+ const reason = String(input.reason || "Balance adjustment").trim();
128
+ const currency = account.currency || "THB";
129
+ const txInput = {
130
+ date,
131
+ description: reason,
132
+ postings: [
133
+ { account_id: debitAccountId, debit: amount, currency },
134
+ { account_id: creditAccountId, credit: amount, currency },
135
+ ],
136
+ };
137
+ let validated;
138
+ try {
139
+ validated = validateTransaction(txInput);
140
+ }
141
+ catch (err) {
142
+ return `Could not build adjustment transaction: ${err.message}`;
143
+ }
144
+ try {
145
+ const tx = db.transaction(() => {
146
+ const equityExisted = !!findAccountById(db, EQUITY_ADJUST_ID);
147
+ if (!equityExisted) {
148
+ ensureStructuralAccount(db, "equity:adjustments");
149
+ if (ctx.correlationId) {
150
+ appendAction(db, {
151
+ correlation_id: ctx.correlationId,
152
+ command: ctx.command ?? "record",
153
+ user_input: ctx.userInput ?? null,
154
+ action_type: "create_account",
155
+ target_id: EQUITY_ADJUST_ID,
156
+ payload: { row: findAccountById(db, EQUITY_ADJUST_ID) },
157
+ });
158
+ }
159
+ }
160
+ insertTransactionRows(db, validated);
161
+ if (ctx.correlationId) {
162
+ appendAction(db, {
163
+ correlation_id: ctx.correlationId,
164
+ command: ctx.command ?? "record",
165
+ user_input: ctx.userInput ?? null,
166
+ action_type: "adjust_balance",
167
+ target_id: validated.id,
168
+ payload: {
169
+ account_id: account.id,
170
+ before_balance: current,
171
+ after_balance: target,
172
+ transaction: { date: validated.date, description: validated.description },
173
+ postings: validated.postings,
174
+ },
175
+ });
176
+ }
177
+ });
178
+ tx();
179
+ }
180
+ catch (err) {
181
+ return `Could not post adjustment transaction: ${err.message}`;
182
+ }
183
+ return `Adjusted ${sanitizeForPrompt(account.name)}: ${formatAmount(current)} → ${formatAmount(target)} (Δ ${delta > 0 ? "+" : ""}${formatAmount(delta)}). Transaction ${validated.id}.`;
184
+ }
185
+ function round2(n) {
186
+ return Math.round(n * 100) / 100;
187
+ }
188
+ export const recordTools = { DEFS, LABELS, execute };