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.
- 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/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
package/dist/ai/tools/review.js
CHANGED
|
@@ -1,51 +1,48 @@
|
|
|
1
1
|
import { deleteAccount, findSimilarAccounts, findUnusedAccounts, mergeAccounts, renameAccount, } from "../../db/queries/account_balance.js";
|
|
2
|
-
import {
|
|
3
|
-
import { findRecurrenceCandidates,
|
|
4
|
-
import {
|
|
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: "
|
|
12
|
-
description: "Header-only update: date, description, or source_page. To change amounts, delete the
|
|
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
|
-
|
|
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: ["
|
|
18
|
+
required: ["transaction_id"],
|
|
22
19
|
},
|
|
23
20
|
},
|
|
24
21
|
{
|
|
25
|
-
name: "
|
|
26
|
-
description: "Safe single-
|
|
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
|
-
|
|
27
|
+
posting_id: { type: "string" },
|
|
31
28
|
account_id: { type: "string" },
|
|
32
29
|
memo: { type: "string" },
|
|
33
30
|
},
|
|
34
|
-
required: ["
|
|
31
|
+
required: ["posting_id"],
|
|
35
32
|
},
|
|
36
33
|
},
|
|
37
34
|
{
|
|
38
|
-
name: "
|
|
39
|
-
description: "Delete
|
|
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: {
|
|
43
|
-
required: ["
|
|
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
|
|
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
|
|
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
|
|
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: "
|
|
75
|
-
description: "Heuristic: groups
|
|
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
|
|
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: "
|
|
102
|
-
description: "Surface pairs of
|
|
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
|
|
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
|
|
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
|
|
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
|
|
132
|
+
amount_typical: { type: "number", description: "Representative amount (typically the matching amount of the member transactions)." },
|
|
136
133
|
currency: { type: "string", default: "THB" },
|
|
137
|
-
|
|
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", "
|
|
137
|
+
required: ["account_id", "description", "frequency", "transaction_ids"],
|
|
141
138
|
},
|
|
142
139
|
},
|
|
143
140
|
{
|
|
144
|
-
name: "
|
|
145
|
-
description: "Attach a single newly-seen
|
|
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
|
-
|
|
146
|
+
transaction_id: { type: "string" },
|
|
150
147
|
recurrence_id: { type: "string" },
|
|
151
148
|
},
|
|
152
|
-
required: ["
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
169
|
+
find_duplicate_transactions: "Finding duplicate transactions",
|
|
173
170
|
find_similar_accounts: "Finding similar accounts",
|
|
174
171
|
find_unused_accounts: "Finding unused accounts",
|
|
175
|
-
|
|
172
|
+
find_correlated_transactions: "Finding correlated transactions",
|
|
176
173
|
find_recurrences: "Finding recurrences",
|
|
177
174
|
record_recurrence: "Recording recurrence",
|
|
178
|
-
|
|
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 "
|
|
180
|
+
case "update_transaction": {
|
|
184
181
|
if (ctx?.dryRun)
|
|
185
|
-
return `Would update
|
|
186
|
-
const changed =
|
|
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
|
-
? `
|
|
193
|
-
: `Updated
|
|
189
|
+
? `Transaction ${input.transaction_id} not found or no fields to update.`
|
|
190
|
+
: `Updated transaction ${input.transaction_id}.`;
|
|
194
191
|
}
|
|
195
|
-
case "
|
|
192
|
+
case "update_posting": {
|
|
196
193
|
if (ctx?.dryRun)
|
|
197
|
-
return `Would update
|
|
198
|
-
const changed =
|
|
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
|
-
? `
|
|
204
|
-
: `Updated
|
|
200
|
+
? `Posting ${input.posting_id} not found or no fields to update.`
|
|
201
|
+
: `Updated posting ${input.posting_id}.`;
|
|
205
202
|
}
|
|
206
|
-
case "
|
|
203
|
+
case "delete_transaction": {
|
|
207
204
|
if (ctx?.dryRun)
|
|
208
|
-
return `Would delete
|
|
209
|
-
const changed =
|
|
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
|
-
? `
|
|
212
|
-
: `Deleted
|
|
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}
|
|
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 "
|
|
245
|
-
const groups =
|
|
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} — ${
|
|
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
|
|
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 "
|
|
280
|
-
const pairs =
|
|
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
|
|
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
|
|
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
|
|
292
|
+
: "(no postings)";
|
|
296
293
|
return [
|
|
297
|
-
`Pair ${i + 1} — ${
|
|
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.
|
|
314
|
-
const ids = c.
|
|
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} — ${
|
|
317
|
-
` Sightings (${c.
|
|
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
|
-
`
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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 "
|
|
340
|
+
case "link_transaction_to_recurrence": {
|
|
344
341
|
if (ctx?.dryRun)
|
|
345
|
-
return `Would link
|
|
342
|
+
return `Would link transaction ${input.transaction_id} → recurrence ${input.recurrence_id}.`;
|
|
346
343
|
try {
|
|
347
|
-
|
|
348
|
-
return `Linked
|
|
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}`;
|
package/dist/ai/tools/scan.js
CHANGED
|
@@ -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
|
|
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: {
|
package/dist/ai/tools/types.d.ts
CHANGED
|
@@ -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 `
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
|
|
32
|
-
|
|
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 {
|
|
5
|
-
|
|
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
|
|
52
|
-
|
|
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
|
|
60
|
-
const
|
|
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
|
|
64
|
-
const
|
|
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 ${
|
|
89
|
+
chalk.dim(`Assets ${formatSignedAmount(assets)}`) +
|
|
82
90
|
chalk.dim(" · ") +
|
|
83
|
-
chalk.dim(`Liabilities ${
|
|
91
|
+
chalk.dim(`Liabilities ${formatSignedAmount(liabilities)}`) +
|
|
84
92
|
chalk.dim(" · ") +
|
|
85
|
-
chalk.bold(`Net worth ${
|
|
93
|
+
chalk.bold(`Net worth ${formatSignedAmount(netWorth)}`));
|
|
86
94
|
}
|
|
@@ -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
|
|
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}`);
|