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.
- package/README.md +9 -9
- package/dist/accounts/taxonomy.d.ts +1 -1
- package/dist/accounts/taxonomy.js +2 -2
- package/dist/ai/agent.d.ts +7 -6
- package/dist/ai/agent.js +9 -8
- package/dist/ai/personas.d.ts +1 -1
- package/dist/ai/personas.js +69 -66
- package/dist/ai/prompt-sections.d.ts +4 -5
- package/dist/ai/prompt-sections.js +11 -11
- package/dist/ai/system-prompt.d.ts +2 -3
- package/dist/ai/system-prompt.js +5 -5
- package/dist/ai/tools/common.js +13 -5
- package/dist/ai/tools/index.js +15 -15
- package/dist/ai/tools/ingest.d.ts +2 -2
- package/dist/ai/tools/ingest.js +210 -87
- package/dist/ai/tools/merchants.js +27 -12
- package/dist/ai/tools/read.js +36 -20
- package/dist/ai/tools/record.js +79 -19
- package/dist/ai/tools/resolve.d.ts +2 -0
- package/dist/ai/tools/resolve.js +195 -0
- package/dist/ai/tools/types.d.ts +5 -7
- package/dist/cli/commands/accounts.js +2 -2
- package/dist/cli/commands/record.js +4 -2
- package/dist/cli/commands/resolve.d.ts +2 -0
- package/dist/cli/commands/resolve.js +13 -0
- package/dist/cli/commands/scan.js +18 -22
- package/dist/cli/commands/status.js +4 -2
- package/dist/cli/index.js +9 -9
- package/dist/cli/ink/hooks/useFooterText.js +1 -1
- package/dist/cli/ink/hooks/useTextInput.js +0 -3
- package/dist/cli/ink/scan_dashboard.d.ts +2 -2
- package/dist/cli/ink/scan_dashboard.js +3 -3
- package/dist/cli/setup.js +6 -3
- package/dist/cli/ux.js +1 -1
- package/dist/db/queries/account-balance.d.ts +140 -0
- package/dist/db/queries/account-balance.js +355 -0
- package/dist/db/queries/account_balance.d.ts +0 -1
- package/dist/db/queries/account_balance.js +0 -10
- package/dist/db/queries/action-log.d.ts +29 -0
- package/dist/db/queries/action-log.js +27 -0
- package/dist/db/queries/action_log.d.ts +1 -1
- package/dist/db/queries/concerns.d.ts +10 -0
- package/dist/db/queries/concerns.js +21 -0
- package/dist/db/queries/transactions.d.ts +3 -22
- package/dist/db/queries/transactions.js +4 -5
- package/dist/db/queries/unknowns.d.ts +62 -0
- package/dist/db/queries/unknowns.js +114 -0
- package/dist/db/schema.js +3 -3
- package/dist/resolver/pipeline.d.ts +16 -0
- package/dist/resolver/pipeline.js +38 -0
- package/dist/resolver/prompts.d.ts +8 -0
- package/dist/resolver/prompts.js +26 -0
- package/dist/scanner/account-mutex.d.ts +1 -0
- package/dist/scanner/account-mutex.js +16 -0
- package/dist/scanner/buffer.d.ts +10 -10
- package/dist/scanner/buffer.js +15 -15
- package/dist/scanner/decrypt-queue.d.ts +57 -0
- package/dist/scanner/decrypt-queue.js +114 -0
- package/dist/scanner/detectors/correlations.d.ts +2 -0
- package/dist/scanner/detectors/correlations.js +51 -0
- package/dist/scanner/detectors/duplicates.d.ts +2 -0
- package/dist/scanner/detectors/duplicates.js +75 -0
- package/dist/scanner/detectors/index.d.ts +18 -0
- package/dist/scanner/detectors/index.js +39 -0
- package/dist/scanner/detectors/recurrences.d.ts +2 -0
- package/dist/scanner/detectors/recurrences.js +49 -0
- package/dist/scanner/detectors/similar_accounts.d.ts +2 -0
- package/dist/scanner/detectors/similar_accounts.js +64 -0
- package/dist/scanner/detectors/similarities.d.ts +2 -0
- package/dist/scanner/detectors/similarities.js +73 -0
- package/dist/scanner/detectors/types.d.ts +16 -0
- package/dist/scanner/detectors/types.js +1 -0
- package/dist/scanner/inspectors/correlations.d.ts +2 -0
- package/dist/scanner/inspectors/correlations.js +47 -0
- package/dist/scanner/inspectors/duplicates.d.ts +2 -0
- package/dist/scanner/inspectors/duplicates.js +75 -0
- package/dist/scanner/inspectors/index.d.ts +19 -0
- package/dist/scanner/inspectors/index.js +39 -0
- package/dist/scanner/inspectors/recurrences.d.ts +2 -0
- package/dist/scanner/inspectors/recurrences.js +49 -0
- package/dist/scanner/inspectors/similarities.d.ts +2 -0
- package/dist/scanner/inspectors/similarities.js +73 -0
- package/dist/scanner/inspectors/types.d.ts +16 -0
- package/dist/scanner/inspectors/types.js +1 -0
- package/dist/scanner/pipeline.d.ts +6 -4
- package/dist/scanner/pipeline.js +51 -88
- package/dist/scanner/prompts.js +2 -2
- package/package.json +2 -1
package/dist/ai/tools/read.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { findAccountById, getAccountBalances, getNetWorth, getPeriodTotals, } from "../../db/queries/
|
|
1
|
+
import { findAccountById, getAccountBalances, getNetWorth, getPeriodTotals, } from "../../db/queries/account-balance.js";
|
|
2
2
|
import { listPostings } from "../../db/queries/transactions.js";
|
|
3
|
-
import {
|
|
3
|
+
import { listOpenUnknowns } from "../../db/queries/unknowns.js";
|
|
4
4
|
import { searchPostings } from "../../db/queries/search.js";
|
|
5
5
|
import { formatAmount } from "../../currency.js";
|
|
6
6
|
import { sanitizeForPrompt, sanitizeForPromptCell } from "../sanitize.js";
|
|
@@ -28,7 +28,10 @@ const DEFS = [
|
|
|
28
28
|
account_id: { type: "string" },
|
|
29
29
|
from: { type: "string", description: "Start date YYYY-MM-DD" },
|
|
30
30
|
to: { type: "string", description: "End date YYYY-MM-DD" },
|
|
31
|
-
q: {
|
|
31
|
+
q: {
|
|
32
|
+
type: "string",
|
|
33
|
+
description: "Free-text contains-match on description, memo, or merchant",
|
|
34
|
+
},
|
|
32
35
|
limit: { type: "number", description: "Max results (default 50)" },
|
|
33
36
|
},
|
|
34
37
|
required: [],
|
|
@@ -59,13 +62,16 @@ const DEFS = [
|
|
|
59
62
|
},
|
|
60
63
|
},
|
|
61
64
|
{
|
|
62
|
-
name: "
|
|
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
|
|
65
|
+
name: "list_open_unknowns",
|
|
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/transaction/account it was attached to. The resolver uses this to drive the step-by-step clarification loop.",
|
|
64
67
|
input_schema: {
|
|
65
68
|
type: "object",
|
|
66
69
|
properties: {
|
|
67
70
|
limit: { type: "number", default: 50 },
|
|
68
|
-
kind: {
|
|
71
|
+
kind: {
|
|
72
|
+
type: "string",
|
|
73
|
+
description: "Optional filter by unknown kind (e.g. 'uncategorized_expense').",
|
|
74
|
+
},
|
|
69
75
|
},
|
|
70
76
|
required: [],
|
|
71
77
|
},
|
|
@@ -77,7 +83,7 @@ const LABELS = {
|
|
|
77
83
|
list_postings: "Listing postings",
|
|
78
84
|
search_transactions: "Searching transactions",
|
|
79
85
|
get_period_totals: "Summing period totals",
|
|
80
|
-
|
|
86
|
+
list_open_unknowns: "Listing open unknowns",
|
|
81
87
|
};
|
|
82
88
|
async function execute(db, name, input, _ctx) {
|
|
83
89
|
switch (name) {
|
|
@@ -86,7 +92,7 @@ async function execute(db, name, input, _ctx) {
|
|
|
86
92
|
if (!acct)
|
|
87
93
|
return `Account "${input.account_id}" not found.`;
|
|
88
94
|
const balances = getAccountBalances(db);
|
|
89
|
-
const bal = balances.find(b => b.id === acct.id)?.balance ?? 0;
|
|
95
|
+
const bal = balances.find((b) => b.id === acct.id)?.balance ?? 0;
|
|
90
96
|
return `${sanitizeForPrompt(acct.name)} (${acct.type}): ${formatAmount(bal)}`;
|
|
91
97
|
}
|
|
92
98
|
case "get_net_worth": {
|
|
@@ -104,10 +110,12 @@ async function execute(db, name, input, _ctx) {
|
|
|
104
110
|
if (rows.length === 0)
|
|
105
111
|
return "No matching postings.";
|
|
106
112
|
return rows
|
|
107
|
-
.map(r => {
|
|
113
|
+
.map((r) => {
|
|
108
114
|
const dr = r.debit > 0 ? `DR ${formatAmount(r.debit)}` : "";
|
|
109
115
|
const cr = r.credit > 0 ? `CR ${formatAmount(r.credit)}` : "";
|
|
110
|
-
const merchant = r.merchant_name
|
|
116
|
+
const merchant = r.merchant_name
|
|
117
|
+
? ` (${sanitizeForPromptCell(r.merchant_name)})`
|
|
118
|
+
: "";
|
|
111
119
|
return `${r.transaction_date} | ${sanitizeForPromptCell(r.transaction_description || "")}${merchant} | ${sanitizeForPromptCell(r.account_name || "")} | ${dr}${cr} | ${sanitizeForPromptCell(r.memo || "")}`;
|
|
112
120
|
})
|
|
113
121
|
.join("\n");
|
|
@@ -117,10 +125,12 @@ async function execute(db, name, input, _ctx) {
|
|
|
117
125
|
if (rows.length === 0)
|
|
118
126
|
return `No matches for "${sanitizeForPrompt(input.query)}".`;
|
|
119
127
|
return rows
|
|
120
|
-
.map(r => {
|
|
128
|
+
.map((r) => {
|
|
121
129
|
const dr = r.debit > 0 ? `DR ${formatAmount(r.debit)}` : "";
|
|
122
130
|
const cr = r.credit > 0 ? `CR ${formatAmount(r.credit)}` : "";
|
|
123
|
-
const merchant = r.merchant_name
|
|
131
|
+
const merchant = r.merchant_name
|
|
132
|
+
? ` (${sanitizeForPromptCell(r.merchant_name)})`
|
|
133
|
+
: "";
|
|
124
134
|
return `${r.transaction_date} | ${sanitizeForPromptCell(r.transaction_description || "")}${merchant} | ${sanitizeForPromptCell(r.account_name || "")} | ${dr}${cr}`;
|
|
125
135
|
})
|
|
126
136
|
.join("\n");
|
|
@@ -129,21 +139,27 @@ async function execute(db, name, input, _ctx) {
|
|
|
129
139
|
const totals = getPeriodTotals(db, input.from, input.to);
|
|
130
140
|
return `Income ${formatAmount(totals.income)} · Expenses ${formatAmount(totals.expenses)} · Net ${formatAmount(totals.income - totals.expenses)}`;
|
|
131
141
|
}
|
|
132
|
-
case "
|
|
133
|
-
const rows =
|
|
134
|
-
const filtered = input.kind
|
|
142
|
+
case "list_open_unknowns": {
|
|
143
|
+
const rows = listOpenUnknowns(db, input.limit ?? 50);
|
|
144
|
+
const filtered = input.kind
|
|
145
|
+
? rows.filter((r) => r.kind === input.kind)
|
|
146
|
+
: rows;
|
|
135
147
|
if (filtered.length === 0)
|
|
136
|
-
return "No open
|
|
148
|
+
return "No open unknowns. The picture is clear.";
|
|
137
149
|
return filtered
|
|
138
|
-
.map(r => {
|
|
150
|
+
.map((r) => {
|
|
139
151
|
const targets = [
|
|
140
152
|
r.transaction_id ? `transaction=${r.transaction_id}` : null,
|
|
141
153
|
r.account_id ? `account=${r.account_id}` : null,
|
|
142
|
-
!r.transaction_id && !r.account_id && r.file_id
|
|
154
|
+
!r.transaction_id && !r.account_id && r.file_id
|
|
155
|
+
? `file=${r.file_id}`
|
|
156
|
+
: null,
|
|
143
157
|
r.kind ? `kind=${r.kind}` : null,
|
|
144
|
-
]
|
|
158
|
+
]
|
|
159
|
+
.filter(Boolean)
|
|
160
|
+
.join(" ");
|
|
145
161
|
const options = r.options_json
|
|
146
|
-
? ` [options: ${JSON.parse(r.options_json).map(o => sanitizeForPrompt(o)).join(" | ")}]`
|
|
162
|
+
? ` [options: ${JSON.parse(r.options_json).map((o) => sanitizeForPrompt(o)).join(" | ")}]`
|
|
147
163
|
: "";
|
|
148
164
|
return `${r.id} ${targets} — ${sanitizeForPrompt(r.prompt)}${options}`;
|
|
149
165
|
})
|
package/dist/ai/tools/record.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { findAccountById, findAccountsByFuzzyName, getAccountBalances, ensureStructuralAccount, } from "../../db/queries/
|
|
1
|
+
import { findAccountById, findAccountsByFuzzyName, getAccountBalances, ensureStructuralAccount, renameAccount, deleteAccount, } from "../../db/queries/account-balance.js";
|
|
2
2
|
import { validateTransaction, insertTransactionRows, } from "../../db/queries/transactions.js";
|
|
3
|
-
import { appendAction } from "../../db/queries/
|
|
3
|
+
import { appendAction } from "../../db/queries/action-log.js";
|
|
4
4
|
import { formatAmount } from "../../currency.js";
|
|
5
5
|
import { sanitizeForPrompt } from "../sanitize.js";
|
|
6
6
|
const EQUITY_ADJUST_ID = "equity:adjustments";
|
|
@@ -10,10 +10,10 @@ function todayIso() {
|
|
|
10
10
|
/**
|
|
11
11
|
* Record-only tool definitions
|
|
12
12
|
*
|
|
13
|
-
* `find_similar_accounts` and `clarify` are reads / prompts;
|
|
14
|
-
* `
|
|
15
|
-
* with `action_type='adjust_balance'`
|
|
16
|
-
*
|
|
13
|
+
* `find_similar_accounts` and `clarify` are reads / prompts; `adjust_account_balance`,
|
|
14
|
+
* `rename_account`, and `delete_account` mutate the DB. Of those, only
|
|
15
|
+
* `adjust_account_balance` writes an action_log row (with `action_type='adjust_balance'`);
|
|
16
|
+
* rename and delete are simple shape changes without an audit entry.
|
|
17
17
|
*/
|
|
18
18
|
const DEFS = [
|
|
19
19
|
{
|
|
@@ -22,8 +22,15 @@ const DEFS = [
|
|
|
22
22
|
input_schema: {
|
|
23
23
|
type: "object",
|
|
24
24
|
properties: {
|
|
25
|
-
query: {
|
|
26
|
-
|
|
25
|
+
query: {
|
|
26
|
+
type: "string",
|
|
27
|
+
description: "Free-text name to match against the chart of accounts.",
|
|
28
|
+
},
|
|
29
|
+
threshold: {
|
|
30
|
+
type: "number",
|
|
31
|
+
description: "Minimum similarity (0-1). Default 0.5.",
|
|
32
|
+
default: 0.5,
|
|
33
|
+
},
|
|
27
34
|
},
|
|
28
35
|
required: ["query"],
|
|
29
36
|
},
|
|
@@ -35,20 +42,53 @@ const DEFS = [
|
|
|
35
42
|
type: "object",
|
|
36
43
|
properties: {
|
|
37
44
|
account_id: { type: "string" },
|
|
38
|
-
target_balance: {
|
|
39
|
-
|
|
40
|
-
|
|
45
|
+
target_balance: {
|
|
46
|
+
type: "number",
|
|
47
|
+
description: "The new desired balance in the account's currency, in natural sign (positive).",
|
|
48
|
+
},
|
|
49
|
+
reason: {
|
|
50
|
+
type: "string",
|
|
51
|
+
description: "Short description, e.g. 'Set DIEM portfolio to current market value (user-asserted).'.",
|
|
52
|
+
},
|
|
53
|
+
date: {
|
|
54
|
+
type: "string",
|
|
55
|
+
description: "ISO YYYY-MM-DD. Defaults to today.",
|
|
56
|
+
},
|
|
41
57
|
},
|
|
42
58
|
required: ["account_id", "target_balance", "reason"],
|
|
43
59
|
},
|
|
44
60
|
},
|
|
61
|
+
{
|
|
62
|
+
name: "rename_account",
|
|
63
|
+
description: "Rename an existing account. Postings and metadata are untouched. Use for utterances like 'rename SCB to Bangkok Bank' once the user-named account is resolved via find_similar_accounts.",
|
|
64
|
+
input_schema: {
|
|
65
|
+
type: "object",
|
|
66
|
+
properties: {
|
|
67
|
+
account_id: { type: "string" },
|
|
68
|
+
name: { type: "string" },
|
|
69
|
+
},
|
|
70
|
+
required: ["account_id", "name"],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "delete_account",
|
|
75
|
+
description: "Delete an account that has no postings and no children. Use for utterances like 'delete my old empty cash account'. Refuses if the account still has postings (merge into another account first) or child accounts (delete or re-parent the children first).",
|
|
76
|
+
input_schema: {
|
|
77
|
+
type: "object",
|
|
78
|
+
properties: { account_id: { type: "string" } },
|
|
79
|
+
required: ["account_id"],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
45
82
|
{
|
|
46
83
|
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
|
|
84
|
+
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 resolve's ask_user, this does NOT write to the unknowns table — record-time questions are transient.",
|
|
48
85
|
input_schema: {
|
|
49
86
|
type: "object",
|
|
50
87
|
properties: {
|
|
51
|
-
prompt: {
|
|
88
|
+
prompt: {
|
|
89
|
+
type: "string",
|
|
90
|
+
description: "The question to ask in plain language.",
|
|
91
|
+
},
|
|
52
92
|
options: {
|
|
53
93
|
type: "array",
|
|
54
94
|
items: { type: "string" },
|
|
@@ -72,6 +112,8 @@ const DEFS = [
|
|
|
72
112
|
const LABELS = {
|
|
73
113
|
find_similar_accounts: "Searching similar accounts",
|
|
74
114
|
adjust_account_balance: "Adjusting balance",
|
|
115
|
+
rename_account: "Renaming account",
|
|
116
|
+
delete_account: "Deleting account",
|
|
75
117
|
clarify: "Asking for clarification",
|
|
76
118
|
};
|
|
77
119
|
async function execute(db, name, input, ctx) {
|
|
@@ -82,11 +124,26 @@ async function execute(db, name, input, ctx) {
|
|
|
82
124
|
return `No accounts matched "${sanitizeForPrompt(input.query ?? "")}".`;
|
|
83
125
|
return matches
|
|
84
126
|
.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}`)
|
|
127
|
+
.map((m) => `${m.account.id} | ${sanitizeForPrompt(m.account.name)} | ${m.account.type}${m.account.subtype ? `/${m.account.subtype}` : ""} | similarity ${m.similarity}`)
|
|
86
128
|
.join("\n");
|
|
87
129
|
}
|
|
88
130
|
case "adjust_account_balance":
|
|
89
131
|
return adjustAccountBalance(db, input, ctx);
|
|
132
|
+
case "rename_account": {
|
|
133
|
+
const changed = renameAccount(db, input.account_id, input.name);
|
|
134
|
+
return changed === 0
|
|
135
|
+
? `Account ${input.account_id} not found.`
|
|
136
|
+
: `Renamed ${input.account_id} → "${sanitizeForPrompt(input.name)}".`;
|
|
137
|
+
}
|
|
138
|
+
case "delete_account": {
|
|
139
|
+
try {
|
|
140
|
+
deleteAccount(db, input.account_id);
|
|
141
|
+
return `Deleted account ${input.account_id}.`;
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
return `Could not delete: ${err.message}`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
90
147
|
case "clarify": {
|
|
91
148
|
if (!ctx)
|
|
92
149
|
return "clarify is only available inside an agent session.";
|
|
@@ -103,8 +160,6 @@ async function execute(db, name, input, ctx) {
|
|
|
103
160
|
async function adjustAccountBalance(db, input, ctx) {
|
|
104
161
|
if (!ctx)
|
|
105
162
|
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
163
|
const account = findAccountById(db, input.account_id);
|
|
109
164
|
if (!account)
|
|
110
165
|
return `Account "${input.account_id}" not found.`;
|
|
@@ -112,7 +167,7 @@ async function adjustAccountBalance(db, input, ctx) {
|
|
|
112
167
|
if (!Number.isFinite(target))
|
|
113
168
|
return `target_balance must be a number, got ${JSON.stringify(input.target_balance)}.`;
|
|
114
169
|
const balances = getAccountBalances(db);
|
|
115
|
-
const current = balances.find(b => b.id === account.id)?.balance ?? 0;
|
|
170
|
+
const current = balances.find((b) => b.id === account.id)?.balance ?? 0;
|
|
116
171
|
const delta = round2(target - current);
|
|
117
172
|
if (delta === 0) {
|
|
118
173
|
return `${sanitizeForPrompt(account.name)} is already at ${formatAmount(target)}; no transaction posted.`;
|
|
@@ -123,7 +178,9 @@ async function adjustAccountBalance(db, input, ctx) {
|
|
|
123
178
|
? account.id
|
|
124
179
|
: EQUITY_ADJUST_ID;
|
|
125
180
|
const creditAccountId = debitAccountId === account.id ? EQUITY_ADJUST_ID : account.id;
|
|
126
|
-
const date = input.date && /^\d{4}-\d{2}-\d{2}$/.test(input.date)
|
|
181
|
+
const date = input.date && /^\d{4}-\d{2}-\d{2}$/.test(input.date)
|
|
182
|
+
? input.date
|
|
183
|
+
: todayIso();
|
|
127
184
|
const reason = String(input.reason || "Balance adjustment").trim();
|
|
128
185
|
const currency = account.currency || "THB";
|
|
129
186
|
const txInput = {
|
|
@@ -169,7 +226,10 @@ async function adjustAccountBalance(db, input, ctx) {
|
|
|
169
226
|
account_id: account.id,
|
|
170
227
|
before_balance: current,
|
|
171
228
|
after_balance: target,
|
|
172
|
-
transaction: {
|
|
229
|
+
transaction: {
|
|
230
|
+
date: validated.date,
|
|
231
|
+
description: validated.description,
|
|
232
|
+
},
|
|
173
233
|
postings: validated.postings,
|
|
174
234
|
},
|
|
175
235
|
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { deleteTransaction, updateTransaction, updatePosting, } from "../../db/queries/transactions.js";
|
|
2
|
+
import { mergeAccounts } from "../../db/queries/account-balance.js";
|
|
3
|
+
import { linkTransactionToRecurrence, recordRecurrence, } from "../../db/queries/recurrences.js";
|
|
4
|
+
import { sanitizeForPrompt } from "../sanitize.js";
|
|
5
|
+
/**
|
|
6
|
+
* Resolve-mode tools: the mutation primitives an agent calls to APPLY the
|
|
7
|
+
* answer to an open unknown. Inspection has already happened (scanner inspectors
|
|
8
|
+
* wrote the unknowns); discovery tools (find_duplicate_transactions,
|
|
9
|
+
* find_recurrences, etc.) don't live here — the resolver iterates unknowns
|
|
10
|
+
* and asks the user, it doesn't search.
|
|
11
|
+
*/
|
|
12
|
+
const DEFS = [
|
|
13
|
+
{
|
|
14
|
+
name: "update_transaction",
|
|
15
|
+
description: "Header-only update: date, description, or source_page. To change amounts, delete the transaction and record a new one.",
|
|
16
|
+
input_schema: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
transaction_id: { type: "string" },
|
|
20
|
+
date: { type: "string" },
|
|
21
|
+
description: { type: "string" },
|
|
22
|
+
source_page: { type: "number" },
|
|
23
|
+
},
|
|
24
|
+
required: ["transaction_id"],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "update_posting",
|
|
29
|
+
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.",
|
|
30
|
+
input_schema: {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: {
|
|
33
|
+
posting_id: { type: "string" },
|
|
34
|
+
account_id: { type: "string" },
|
|
35
|
+
memo: { type: "string" },
|
|
36
|
+
},
|
|
37
|
+
required: ["posting_id"],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "delete_transaction",
|
|
42
|
+
description: "Delete a transaction and (via cascade) all its postings. The primitive for removing duplicates.",
|
|
43
|
+
input_schema: {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: { transaction_id: { type: "string" } },
|
|
46
|
+
required: ["transaction_id"],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "record_recurrence",
|
|
51
|
+
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 unknown.",
|
|
52
|
+
input_schema: {
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: {
|
|
55
|
+
account_id: {
|
|
56
|
+
type: "string",
|
|
57
|
+
description: "The account this recurs on.",
|
|
58
|
+
},
|
|
59
|
+
description: {
|
|
60
|
+
type: "string",
|
|
61
|
+
description: "Human label, e.g. 'Spotify subscription', 'Salary', 'Rent'.",
|
|
62
|
+
},
|
|
63
|
+
frequency: {
|
|
64
|
+
type: "string",
|
|
65
|
+
enum: ["weekly", "biweekly", "monthly", "annually"],
|
|
66
|
+
},
|
|
67
|
+
amount_typical: {
|
|
68
|
+
type: "number",
|
|
69
|
+
description: "Representative amount (typically the matching amount of the member transactions).",
|
|
70
|
+
},
|
|
71
|
+
currency: { type: "string", default: "THB" },
|
|
72
|
+
transaction_ids: {
|
|
73
|
+
type: "array",
|
|
74
|
+
items: { type: "string" },
|
|
75
|
+
description: "Transaction ids to link to this recurrence.",
|
|
76
|
+
},
|
|
77
|
+
notes: {
|
|
78
|
+
type: "string",
|
|
79
|
+
description: "Optional context the chat agent can read later.",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
required: ["account_id", "description", "frequency", "transaction_ids"],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: "link_transaction_to_recurrence",
|
|
87
|
+
description: "Attach a single newly-seen transaction to an existing recurrence. Recomputes last_seen_date and next_expected_date on the recurrence.",
|
|
88
|
+
input_schema: {
|
|
89
|
+
type: "object",
|
|
90
|
+
properties: {
|
|
91
|
+
transaction_id: { type: "string" },
|
|
92
|
+
recurrence_id: { type: "string" },
|
|
93
|
+
},
|
|
94
|
+
required: ["transaction_id", "recurrence_id"],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: "merge_accounts",
|
|
99
|
+
description: "Move every posting on `from_id` over to `to_id`, then delete the source account. Use to apply a similar_accounts unknown's 'Merge A into B' resolution. Refuses if the source still has child accounts.",
|
|
100
|
+
input_schema: {
|
|
101
|
+
type: "object",
|
|
102
|
+
properties: { from_id: { type: "string" }, to_id: { type: "string" } },
|
|
103
|
+
required: ["from_id", "to_id"],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "mark_resolve_done",
|
|
108
|
+
description: "Call once the current unknown's resolution has been applied. The summary is shown to the user. The pipeline will then mark the unknown resolved and move to the next one.",
|
|
109
|
+
input_schema: {
|
|
110
|
+
type: "object",
|
|
111
|
+
properties: { summary: { type: "string" } },
|
|
112
|
+
required: ["summary"],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
const LABELS = {
|
|
117
|
+
update_transaction: "Updating transaction",
|
|
118
|
+
update_posting: "Updating posting",
|
|
119
|
+
delete_transaction: "Deleting transaction",
|
|
120
|
+
record_recurrence: "Recording recurrence",
|
|
121
|
+
link_transaction_to_recurrence: "Linking transaction to recurrence",
|
|
122
|
+
merge_accounts: "Merging accounts",
|
|
123
|
+
mark_resolve_done: "Finalizing unknown",
|
|
124
|
+
};
|
|
125
|
+
async function execute(db, name, input, ctx) {
|
|
126
|
+
switch (name) {
|
|
127
|
+
case "update_transaction": {
|
|
128
|
+
const changed = updateTransaction(db, input.transaction_id, {
|
|
129
|
+
date: input.date,
|
|
130
|
+
description: input.description,
|
|
131
|
+
source_page: input.source_page,
|
|
132
|
+
});
|
|
133
|
+
return changed === 0
|
|
134
|
+
? `Transaction ${input.transaction_id} not found or no fields to update.`
|
|
135
|
+
: `Updated transaction ${input.transaction_id}.`;
|
|
136
|
+
}
|
|
137
|
+
case "update_posting": {
|
|
138
|
+
const changed = updatePosting(db, input.posting_id, {
|
|
139
|
+
account_id: input.account_id,
|
|
140
|
+
memo: input.memo,
|
|
141
|
+
});
|
|
142
|
+
return changed === 0
|
|
143
|
+
? `Posting ${input.posting_id} not found or no fields to update.`
|
|
144
|
+
: `Updated posting ${input.posting_id}.`;
|
|
145
|
+
}
|
|
146
|
+
case "delete_transaction": {
|
|
147
|
+
const changed = deleteTransaction(db, input.transaction_id);
|
|
148
|
+
return changed === 0
|
|
149
|
+
? `Transaction ${input.transaction_id} not found.`
|
|
150
|
+
: `Deleted transaction ${input.transaction_id} and its postings.`;
|
|
151
|
+
}
|
|
152
|
+
case "record_recurrence": {
|
|
153
|
+
try {
|
|
154
|
+
const id = recordRecurrence(db, {
|
|
155
|
+
account_id: input.account_id,
|
|
156
|
+
description: input.description,
|
|
157
|
+
frequency: input.frequency,
|
|
158
|
+
amount_typical: input.amount_typical ?? null,
|
|
159
|
+
currency: input.currency,
|
|
160
|
+
transaction_ids: input.transaction_ids || [],
|
|
161
|
+
notes: input.notes ?? null,
|
|
162
|
+
});
|
|
163
|
+
return `Recorded recurrence ${id} ("${sanitizeForPrompt(input.description)}", ${input.frequency}); linked ${(input.transaction_ids || []).length} transaction(s).`;
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
return `Could not record recurrence: ${err.message}`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
case "link_transaction_to_recurrence": {
|
|
170
|
+
try {
|
|
171
|
+
linkTransactionToRecurrence(db, input.transaction_id, input.recurrence_id);
|
|
172
|
+
return `Linked transaction ${input.transaction_id} → recurrence ${input.recurrence_id}.`;
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
return `Could not link: ${err.message}`;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
case "merge_accounts": {
|
|
179
|
+
try {
|
|
180
|
+
const moved = mergeAccounts(db, input.from_id, input.to_id);
|
|
181
|
+
return `Merged ${input.from_id} → ${input.to_id}; moved ${moved} posting(s).`;
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
return `Could not merge: ${err.message}`;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
case "mark_resolve_done": {
|
|
188
|
+
ctx?.onComplete?.(input.summary || "");
|
|
189
|
+
return `Unknown done. Summary: ${sanitizeForPrompt(input.summary || "")}`;
|
|
190
|
+
}
|
|
191
|
+
default:
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
export const resolveTools = { DEFS, LABELS, execute };
|
package/dist/ai/tools/types.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
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" | "
|
|
4
|
+
export type ToolProfile = "scan" | "chat" | "resolve" | "record";
|
|
5
5
|
/**
|
|
6
|
-
* Structured highlights the
|
|
6
|
+
* Structured highlights the resolve agent can pass to ask_user. The prompter
|
|
7
7
|
* renders them as a single colored header line above the question (each
|
|
8
8
|
* category gets its own chalk color), so the user can scan amount / date /
|
|
9
9
|
* merchant / accounts at a glance without parsing prose.
|
|
@@ -19,23 +19,21 @@ export interface AgentExecutionContext {
|
|
|
19
19
|
fileId?: string;
|
|
20
20
|
/** When false, ask_user returns a marker and the caller halts after the run. */
|
|
21
21
|
interactive: boolean;
|
|
22
|
-
/** When true, mutating tools become no-ops that return a "would do X" preview. */
|
|
23
|
-
dryRun?: boolean;
|
|
24
22
|
/** Synchronously prompt the user (only invoked when interactive === true). */
|
|
25
23
|
promptUser?: (prompt: string, options?: string[], facts?: PromptUserFacts) => Promise<string>;
|
|
26
|
-
/** Called when the model declares the session is done (scan or
|
|
24
|
+
/** Called when the model declares the session is done (scan or resolve). */
|
|
27
25
|
onComplete?: (summary: string) => void;
|
|
28
26
|
/**
|
|
29
27
|
* Which top-level command this agent serves. Mutating tools branch on this
|
|
30
28
|
* to decide whether to append an action_log row (currently only "record").
|
|
31
29
|
*/
|
|
32
|
-
command?: "scan" | "
|
|
30
|
+
command?: "scan" | "resolve" | "record";
|
|
33
31
|
/** Per-invocation id grouping every action_log row from one CLI run. */
|
|
34
32
|
correlationId?: string;
|
|
35
33
|
/** The raw user utterance / file path that started this invocation. */
|
|
36
34
|
userInput?: string;
|
|
37
35
|
/**
|
|
38
|
-
* Scan-only: when set, transactions and
|
|
36
|
+
* Scan-only: when set, transactions and unknowns are queued here instead of
|
|
39
37
|
* being written directly to the DB. Account and merchant writes still hit
|
|
40
38
|
* the DB eagerly (serialized via their own mutexes) so concurrent scan
|
|
41
39
|
* agents share the same chart of accounts and merchant directory.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { getDb } from "../../db/connection.js";
|
|
3
|
-
import { getAccountBalances } from "../../db/queries/
|
|
3
|
+
import { getAccountBalances } from "../../db/queries/account-balance.js";
|
|
4
4
|
import { visibleLength } from "../format.js";
|
|
5
5
|
import { formatSignedAmount } from "../../currency.js";
|
|
6
6
|
const TYPE_TAG = {
|
|
@@ -42,7 +42,7 @@ export function showAccounts() {
|
|
|
42
42
|
console.log(chalk.yellow("No accounts yet. Drop your bank/credit card statements into ~/.plasalid/data/ and run `plasalid scan`."));
|
|
43
43
|
return;
|
|
44
44
|
}
|
|
45
|
-
const byId = new Map(raw.map(a => [a.id, a]));
|
|
45
|
+
const byId = new Map(raw.map((a) => [a.id, a]));
|
|
46
46
|
const depthCache = new Map();
|
|
47
47
|
const depthOf = (id) => {
|
|
48
48
|
if (depthCache.has(id))
|
|
@@ -3,7 +3,7 @@ import { randomUUID } from "crypto";
|
|
|
3
3
|
import { getDb } from "../../db/connection.js";
|
|
4
4
|
import { runRecordAgent } from "../../ai/agent.js";
|
|
5
5
|
import { makePromptUser, makeAgentOnProgress, statusSpinner } from "../ux.js";
|
|
6
|
-
import { listActions } from "../../db/queries/
|
|
6
|
+
import { listActions } from "../../db/queries/action-log.js";
|
|
7
7
|
import { formatAmount } from "../../currency.js";
|
|
8
8
|
export async function runRecordCommand(opts) {
|
|
9
9
|
const utterance = opts.utterance.trim();
|
|
@@ -73,7 +73,9 @@ function describeAction(a) {
|
|
|
73
73
|
const date = payload?.transaction?.date ?? "";
|
|
74
74
|
const desc = payload?.transaction?.description ?? "";
|
|
75
75
|
const total = totalDebit(payload?.postings);
|
|
76
|
-
const amount = total != null
|
|
76
|
+
const amount = total != null
|
|
77
|
+
? ` ${formatTotal(total, currencyOf(payload?.postings))}`
|
|
78
|
+
: "";
|
|
77
79
|
return `record_transaction ${a.target_id} — ${[date, desc].filter(Boolean).join(" ")}${amount}`;
|
|
78
80
|
}
|
|
79
81
|
case "adjust_balance": {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { runResolve } from "../../resolver/pipeline.js";
|
|
3
|
+
export async function runResolveCommand(opts) {
|
|
4
|
+
try {
|
|
5
|
+
const summary = await runResolve(opts);
|
|
6
|
+
console.log("");
|
|
7
|
+
console.log(chalk.bold(summary));
|
|
8
|
+
}
|
|
9
|
+
catch (err) {
|
|
10
|
+
console.error(chalk.red(`Resolve failed: ${err.message}`));
|
|
11
|
+
process.exitCode = 1;
|
|
12
|
+
}
|
|
13
|
+
}
|