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.
- package/README.md +15 -14
- package/dist/ai/agent.d.ts +15 -2
- package/dist/ai/agent.js +21 -2
- package/dist/ai/memory.d.ts +2 -0
- package/dist/ai/memory.js +2 -2
- package/dist/ai/personas.d.ts +2 -1
- package/dist/ai/personas.js +115 -45
- package/dist/ai/prompt-sections.d.ts +5 -0
- package/dist/ai/prompt-sections.js +26 -8
- package/dist/ai/system-prompt.d.ts +11 -0
- package/dist/ai/system-prompt.js +21 -6
- package/dist/ai/thinking.js +1 -1
- package/dist/ai/tools/common.js +2 -5
- package/dist/ai/tools/index.js +28 -8
- package/dist/ai/tools/ingest.d.ts +2 -1
- package/dist/ai/tools/ingest.js +262 -151
- package/dist/ai/tools/merchants.d.ts +2 -0
- package/dist/ai/tools/merchants.js +117 -0
- package/dist/ai/tools/read.js +31 -29
- package/dist/ai/tools/record.d.ts +2 -0
- package/dist/ai/tools/record.js +188 -0
- package/dist/ai/tools/review.js +77 -80
- package/dist/ai/tools/scan.js +1 -1
- package/dist/ai/tools/types.d.ts +15 -6
- package/dist/cli/commands/accounts.js +33 -25
- package/dist/cli/commands/record.d.ts +4 -0
- package/dist/cli/commands/record.js +119 -0
- package/dist/cli/commands/revert.js +1 -1
- package/dist/cli/commands/scan.js +15 -19
- package/dist/cli/commands/status.js +6 -9
- package/dist/cli/commands/transactions.js +36 -41
- package/dist/cli/format.d.ts +2 -0
- package/dist/cli/format.js +7 -2
- package/dist/cli/index.js +19 -7
- package/dist/cli/ink/hooks/useFooterText.js +2 -1
- package/dist/cli/ink/scan_dashboard.d.ts +1 -1
- package/dist/cli/ink/scan_dashboard.js +2 -2
- package/dist/cli/setup.d.ts +0 -1
- package/dist/cli/setup.js +2 -8
- package/dist/currency.d.ts +3 -0
- package/dist/currency.js +12 -1
- package/dist/db/queries/account_balance.d.ts +83 -4
- package/dist/db/queries/account_balance.js +239 -20
- package/dist/db/queries/action_log.d.ts +29 -0
- package/dist/db/queries/action_log.js +27 -0
- package/dist/db/queries/concerns.d.ts +10 -7
- package/dist/db/queries/concerns.js +20 -16
- package/dist/db/queries/journal.d.ts +1 -0
- package/dist/db/queries/merchants.d.ts +42 -0
- package/dist/db/queries/merchants.js +120 -0
- package/dist/db/queries/recurrences.d.ts +3 -3
- package/dist/db/queries/recurrences.js +32 -34
- package/dist/db/queries/search.d.ts +5 -4
- package/dist/db/queries/search.js +16 -12
- package/dist/db/queries/transactions.d.ts +167 -0
- package/dist/db/queries/transactions.js +320 -0
- package/dist/db/schema.js +51 -9
- package/dist/reviewer/pipeline.d.ts +4 -4
- package/dist/reviewer/pipeline.js +4 -4
- package/dist/reviewer/prompts.js +4 -4
- package/dist/scanner/buffer.d.ts +24 -21
- package/dist/scanner/buffer.js +18 -18
- package/dist/scanner/pipeline.d.ts +3 -2
- package/dist/scanner/pipeline.js +33 -36
- package/dist/scanner/prompts.js +3 -3
- 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 };
|
package/dist/ai/tools/read.js
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { findAccountById, getAccountBalances, getNetWorth, getPeriodTotals, } from "../../db/queries/account_balance.js";
|
|
2
|
-
import {
|
|
2
|
+
import { listPostings } from "../../db/queries/transactions.js";
|
|
3
3
|
import { listOpenConcerns } from "../../db/queries/concerns.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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: "
|
|
27
|
-
description: "List
|
|
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
|
|
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
|
|
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/
|
|
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
|
-
|
|
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}): ${
|
|
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: ${
|
|
94
|
+
return `Net worth: ${formatAmount(nw.net_worth)} (assets ${formatAmount(nw.assets)} − liabilities ${formatAmount(nw.liabilities)})`;
|
|
97
95
|
}
|
|
98
|
-
case "
|
|
99
|
-
const rows =
|
|
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
|
|
105
|
+
return "No matching postings.";
|
|
108
106
|
return rows
|
|
109
107
|
.map(r => {
|
|
110
|
-
const dr = r.debit > 0 ? `DR ${
|
|
111
|
-
const cr = r.credit > 0 ? `CR ${
|
|
112
|
-
|
|
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 =
|
|
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 ${
|
|
123
|
-
const cr = r.credit > 0 ? `CR ${
|
|
124
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
137
|
+
return filtered
|
|
137
138
|
.map(r => {
|
|
138
139
|
const targets = [
|
|
139
|
-
r.
|
|
140
|
+
r.transaction_id ? `transaction=${r.transaction_id}` : null,
|
|
140
141
|
r.account_id ? `account=${r.account_id}` : null,
|
|
141
|
-
!r.
|
|
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,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 };
|