plasalid 0.3.5 → 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 +33 -43
- package/dist/accounts/taxonomy.d.ts +1 -1
- package/dist/accounts/taxonomy.js +2 -2
- package/dist/ai/agent.d.ts +19 -5
- package/dist/ai/agent.js +26 -6
- package/dist/ai/memory.d.ts +14 -5
- package/dist/ai/memory.js +12 -0
- package/dist/ai/personas.d.ts +11 -0
- package/dist/ai/personas.js +193 -0
- package/dist/ai/prompt-sections.d.ts +49 -0
- package/dist/ai/prompt-sections.js +107 -0
- package/dist/ai/system-prompt.d.ts +14 -3
- package/dist/ai/system-prompt.js +59 -165
- package/dist/ai/thinking.js +1 -1
- package/dist/ai/tools/common.js +2 -5
- package/dist/ai/tools/index.js +32 -7
- package/dist/ai/tools/ingest.d.ts +3 -1
- package/dist/ai/tools/ingest.js +372 -124
- package/dist/ai/tools/merchants.d.ts +2 -0
- package/dist/ai/tools/merchants.js +117 -0
- package/dist/ai/tools/read.js +57 -24
- package/dist/ai/tools/record.d.ts +2 -0
- package/dist/ai/tools/record.js +188 -0
- package/dist/ai/tools/review.d.ts +2 -0
- package/dist/ai/tools/review.js +359 -0
- package/dist/ai/tools/scan.js +5 -3
- package/dist/ai/tools/types.d.ts +33 -4
- 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/review.d.ts +2 -0
- package/dist/cli/commands/review.js +15 -0
- package/dist/cli/commands/scan.d.ts +4 -2
- package/dist/cli/commands/scan.js +143 -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 +28 -13
- package/dist/cli/ink/scan_dashboard.d.ts +38 -0
- package/dist/cli/ink/scan_dashboard.js +62 -0
- package/dist/cli/setup.d.ts +0 -1
- package/dist/cli/setup.js +2 -8
- package/dist/cli/ux.d.ts +2 -1
- package/dist/cli/ux.js +36 -2
- package/dist/currency.d.ts +3 -0
- package/dist/currency.js +12 -1
- package/dist/db/queries/account_balance.d.ts +84 -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 +50 -0
- package/dist/db/queries/concerns.js +91 -0
- package/dist/db/queries/journal.d.ts +75 -8
- package/dist/db/queries/journal.js +131 -19
- package/dist/db/queries/merchants.d.ts +42 -0
- package/dist/db/queries/merchants.js +120 -0
- package/dist/db/queries/recurrences.d.ts +33 -0
- package/dist/db/queries/recurrences.js +128 -0
- 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 +74 -9
- package/dist/reviewer/pipeline.d.ts +18 -0
- package/dist/reviewer/pipeline.js +46 -0
- package/dist/reviewer/prompts.d.ts +12 -0
- package/dist/reviewer/prompts.js +22 -0
- package/dist/scanner/account_mutex.d.ts +1 -0
- package/dist/scanner/account_mutex.js +16 -0
- package/dist/scanner/buffer.d.ts +51 -0
- package/dist/scanner/buffer.js +63 -0
- package/dist/scanner/concurrency.d.ts +14 -0
- package/dist/scanner/concurrency.js +31 -0
- package/dist/scanner/decrypt_queue.d.ts +57 -0
- package/dist/scanner/decrypt_queue.js +96 -0
- package/dist/scanner/pipeline.d.ts +47 -18
- package/dist/scanner/pipeline.js +247 -97
- package/dist/scanner/prompts.js +3 -3
- package/package.json +2 -2
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { deleteAccount, findSimilarAccounts, findUnusedAccounts, mergeAccounts, renameAccount, } from "../../db/queries/account_balance.js";
|
|
2
|
+
import { deleteTransaction, findCorrelatedTransactions, findDuplicateTransactions, updateTransaction, updatePosting, } from "../../db/queries/transactions.js";
|
|
3
|
+
import { findRecurrenceCandidates, linkTransactionToRecurrence, recordRecurrence, } from "../../db/queries/recurrences.js";
|
|
4
|
+
import { formatAmount } from "../../currency.js";
|
|
5
|
+
import { sanitizeForPrompt } from "../sanitize.js";
|
|
6
|
+
const DEFS = [
|
|
7
|
+
{
|
|
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.",
|
|
10
|
+
input_schema: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: {
|
|
13
|
+
transaction_id: { type: "string" },
|
|
14
|
+
date: { type: "string" },
|
|
15
|
+
description: { type: "string" },
|
|
16
|
+
source_page: { type: "number" },
|
|
17
|
+
},
|
|
18
|
+
required: ["transaction_id"],
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{
|
|
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.",
|
|
24
|
+
input_schema: {
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {
|
|
27
|
+
posting_id: { type: "string" },
|
|
28
|
+
account_id: { type: "string" },
|
|
29
|
+
memo: { type: "string" },
|
|
30
|
+
},
|
|
31
|
+
required: ["posting_id"],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "delete_transaction",
|
|
36
|
+
description: "Delete a transaction and (via cascade) all its postings. The primitive for removing duplicates.",
|
|
37
|
+
input_schema: {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: { transaction_id: { type: "string" } },
|
|
40
|
+
required: ["transaction_id"],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "rename_account",
|
|
45
|
+
description: "Rename an account. Leaves postings and metadata untouched.",
|
|
46
|
+
input_schema: {
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: { account_id: { type: "string" }, name: { type: "string" } },
|
|
49
|
+
required: ["account_id", "name"],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "merge_accounts",
|
|
54
|
+
description: "Move every posting on `from_id` over to `to_id`, then delete the source account. Use to collapse duplicate accounts. Refuses if the source still has child accounts.",
|
|
55
|
+
input_schema: {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: { from_id: { type: "string" }, to_id: { type: "string" } },
|
|
58
|
+
required: ["from_id", "to_id"],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "delete_account",
|
|
63
|
+
description: "Delete an account that has no postings and no children. Refuses if any posting or child still references it — merge first.",
|
|
64
|
+
input_schema: {
|
|
65
|
+
type: "object",
|
|
66
|
+
properties: { account_id: { type: "string" } },
|
|
67
|
+
required: ["account_id"],
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
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.",
|
|
73
|
+
input_schema: {
|
|
74
|
+
type: "object",
|
|
75
|
+
properties: {
|
|
76
|
+
tolerance_days: { type: "number", default: 2 },
|
|
77
|
+
account_id: { type: "string" },
|
|
78
|
+
min_amount: { type: "number" },
|
|
79
|
+
},
|
|
80
|
+
required: [],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "find_similar_accounts",
|
|
85
|
+
description: "Pairwise Levenshtein similarity on account names. Returns pairs above the threshold, sorted highest first.",
|
|
86
|
+
input_schema: {
|
|
87
|
+
type: "object",
|
|
88
|
+
properties: { threshold: { type: "number", default: 0.85 } },
|
|
89
|
+
required: [],
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: "find_unused_accounts",
|
|
94
|
+
description: "Accounts with zero postings and no children (excludes the five top-level type roots).",
|
|
95
|
+
input_schema: { type: "object", properties: {}, required: [] },
|
|
96
|
+
},
|
|
97
|
+
{
|
|
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).",
|
|
100
|
+
input_schema: {
|
|
101
|
+
type: "object",
|
|
102
|
+
properties: {
|
|
103
|
+
from: { type: "string", description: "ISO date inclusive lower bound (YYYY-MM-DD)." },
|
|
104
|
+
to: { type: "string", description: "ISO date inclusive upper bound (YYYY-MM-DD)." },
|
|
105
|
+
tolerance_days: { type: "number", default: 3, description: "Max day gap between paired transactions." },
|
|
106
|
+
min_amount: { type: "number", default: 0 },
|
|
107
|
+
},
|
|
108
|
+
required: [],
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "find_recurrences",
|
|
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.",
|
|
114
|
+
input_schema: {
|
|
115
|
+
type: "object",
|
|
116
|
+
properties: {
|
|
117
|
+
account_id: { type: "string", description: "Limit to one account; omit for all." },
|
|
118
|
+
min_occurrences: { type: "number", default: 3, description: "Minimum sightings to qualify." },
|
|
119
|
+
},
|
|
120
|
+
required: [],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "record_recurrence",
|
|
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.",
|
|
126
|
+
input_schema: {
|
|
127
|
+
type: "object",
|
|
128
|
+
properties: {
|
|
129
|
+
account_id: { type: "string", description: "The account this recurs on." },
|
|
130
|
+
description: { type: "string", description: "Human label, e.g. 'Spotify subscription', 'Salary', 'Rent'." },
|
|
131
|
+
frequency: { type: "string", enum: ["weekly", "biweekly", "monthly", "annually"] },
|
|
132
|
+
amount_typical: { type: "number", description: "Representative amount (typically the matching amount of the member transactions)." },
|
|
133
|
+
currency: { type: "string", default: "THB" },
|
|
134
|
+
transaction_ids: { type: "array", items: { type: "string" }, description: "Transaction ids to link to this recurrence." },
|
|
135
|
+
notes: { type: "string", description: "Optional context the chat agent can read later." },
|
|
136
|
+
},
|
|
137
|
+
required: ["account_id", "description", "frequency", "transaction_ids"],
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
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.",
|
|
143
|
+
input_schema: {
|
|
144
|
+
type: "object",
|
|
145
|
+
properties: {
|
|
146
|
+
transaction_id: { type: "string" },
|
|
147
|
+
recurrence_id: { type: "string" },
|
|
148
|
+
},
|
|
149
|
+
required: ["transaction_id", "recurrence_id"],
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: "mark_review_done",
|
|
154
|
+
description: "Call when the review pass is complete. The summary is shown to the user.",
|
|
155
|
+
input_schema: {
|
|
156
|
+
type: "object",
|
|
157
|
+
properties: { summary: { type: "string" } },
|
|
158
|
+
required: ["summary"],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
const LABELS = {
|
|
163
|
+
update_transaction: "Updating transaction",
|
|
164
|
+
update_posting: "Updating posting",
|
|
165
|
+
delete_transaction: "Deleting transaction",
|
|
166
|
+
rename_account: "Renaming account",
|
|
167
|
+
merge_accounts: "Merging accounts",
|
|
168
|
+
delete_account: "Deleting account",
|
|
169
|
+
find_duplicate_transactions: "Finding duplicate transactions",
|
|
170
|
+
find_similar_accounts: "Finding similar accounts",
|
|
171
|
+
find_unused_accounts: "Finding unused accounts",
|
|
172
|
+
find_correlated_transactions: "Finding correlated transactions",
|
|
173
|
+
find_recurrences: "Finding recurrences",
|
|
174
|
+
record_recurrence: "Recording recurrence",
|
|
175
|
+
link_transaction_to_recurrence: "Linking transaction to recurrence",
|
|
176
|
+
mark_review_done: "Finalizing review",
|
|
177
|
+
};
|
|
178
|
+
async function execute(db, name, input, ctx) {
|
|
179
|
+
switch (name) {
|
|
180
|
+
case "update_transaction": {
|
|
181
|
+
if (ctx?.dryRun)
|
|
182
|
+
return `Would update transaction ${input.transaction_id}: ${JSON.stringify(input)}`;
|
|
183
|
+
const changed = updateTransaction(db, input.transaction_id, {
|
|
184
|
+
date: input.date,
|
|
185
|
+
description: input.description,
|
|
186
|
+
source_page: input.source_page,
|
|
187
|
+
});
|
|
188
|
+
return changed === 0
|
|
189
|
+
? `Transaction ${input.transaction_id} not found or no fields to update.`
|
|
190
|
+
: `Updated transaction ${input.transaction_id}.`;
|
|
191
|
+
}
|
|
192
|
+
case "update_posting": {
|
|
193
|
+
if (ctx?.dryRun)
|
|
194
|
+
return `Would update posting ${input.posting_id}: ${JSON.stringify(input)}`;
|
|
195
|
+
const changed = updatePosting(db, input.posting_id, {
|
|
196
|
+
account_id: input.account_id,
|
|
197
|
+
memo: input.memo,
|
|
198
|
+
});
|
|
199
|
+
return changed === 0
|
|
200
|
+
? `Posting ${input.posting_id} not found or no fields to update.`
|
|
201
|
+
: `Updated posting ${input.posting_id}.`;
|
|
202
|
+
}
|
|
203
|
+
case "delete_transaction": {
|
|
204
|
+
if (ctx?.dryRun)
|
|
205
|
+
return `Would delete transaction ${input.transaction_id} (and its postings).`;
|
|
206
|
+
const changed = deleteTransaction(db, input.transaction_id);
|
|
207
|
+
return changed === 0
|
|
208
|
+
? `Transaction ${input.transaction_id} not found.`
|
|
209
|
+
: `Deleted transaction ${input.transaction_id} and its postings.`;
|
|
210
|
+
}
|
|
211
|
+
case "rename_account": {
|
|
212
|
+
if (ctx?.dryRun)
|
|
213
|
+
return `Would rename ${input.account_id} → "${input.name}".`;
|
|
214
|
+
const changed = renameAccount(db, input.account_id, input.name);
|
|
215
|
+
return changed === 0
|
|
216
|
+
? `Account ${input.account_id} not found.`
|
|
217
|
+
: `Renamed ${input.account_id} → "${sanitizeForPrompt(input.name)}".`;
|
|
218
|
+
}
|
|
219
|
+
case "merge_accounts": {
|
|
220
|
+
if (ctx?.dryRun)
|
|
221
|
+
return `Would merge ${input.from_id} → ${input.to_id}.`;
|
|
222
|
+
try {
|
|
223
|
+
const moved = mergeAccounts(db, input.from_id, input.to_id);
|
|
224
|
+
return `Merged ${input.from_id} → ${input.to_id}; moved ${moved} posting(s).`;
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
return `Could not merge: ${err.message}`;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
case "delete_account": {
|
|
231
|
+
if (ctx?.dryRun)
|
|
232
|
+
return `Would delete account ${input.account_id}.`;
|
|
233
|
+
try {
|
|
234
|
+
deleteAccount(db, input.account_id);
|
|
235
|
+
return `Deleted account ${input.account_id}.`;
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
return `Could not delete: ${err.message}`;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
case "find_duplicate_transactions": {
|
|
242
|
+
const groups = findDuplicateTransactions(db, {
|
|
243
|
+
toleranceDays: input.tolerance_days,
|
|
244
|
+
accountId: input.account_id,
|
|
245
|
+
minAmount: input.min_amount,
|
|
246
|
+
});
|
|
247
|
+
if (groups.length === 0)
|
|
248
|
+
return "No candidate duplicate groups found.";
|
|
249
|
+
return groups
|
|
250
|
+
.map((g, i) => {
|
|
251
|
+
const header = `Group ${i + 1} — ${formatAmount(g[0].amount)}`;
|
|
252
|
+
const lines = g.map((e, j) => {
|
|
253
|
+
const accounts = e.account_names.length > 0
|
|
254
|
+
? e.account_names.map(n => sanitizeForPrompt(n)).join(", ")
|
|
255
|
+
: "(no postings)";
|
|
256
|
+
return ` ${j + 1}. ${e.date} "${sanitizeForPrompt(e.description)}" — ${accounts} [${e.id}]`;
|
|
257
|
+
});
|
|
258
|
+
return `${header}\n${lines.join("\n")}`;
|
|
259
|
+
})
|
|
260
|
+
.join("\n\n");
|
|
261
|
+
}
|
|
262
|
+
case "find_similar_accounts": {
|
|
263
|
+
const pairs = findSimilarAccounts(db, input.threshold);
|
|
264
|
+
if (pairs.length === 0)
|
|
265
|
+
return "No similar account pairs above threshold.";
|
|
266
|
+
return pairs
|
|
267
|
+
.map(p => `${p.similarity}: ${p.a.id} (${sanitizeForPrompt(p.a.name)}) <-> ${p.b.id} (${sanitizeForPrompt(p.b.name)})`)
|
|
268
|
+
.join("\n");
|
|
269
|
+
}
|
|
270
|
+
case "find_unused_accounts": {
|
|
271
|
+
const rows = findUnusedAccounts(db);
|
|
272
|
+
if (rows.length === 0)
|
|
273
|
+
return "No unused accounts.";
|
|
274
|
+
return rows.map(a => `${a.id} | ${sanitizeForPrompt(a.name)} | ${a.type}`).join("\n");
|
|
275
|
+
}
|
|
276
|
+
case "find_correlated_transactions": {
|
|
277
|
+
const pairs = findCorrelatedTransactions(db, {
|
|
278
|
+
from: input.from,
|
|
279
|
+
to: input.to,
|
|
280
|
+
toleranceDays: input.tolerance_days,
|
|
281
|
+
minAmount: input.min_amount,
|
|
282
|
+
});
|
|
283
|
+
if (pairs.length === 0)
|
|
284
|
+
return "No correlated transaction pairs found.";
|
|
285
|
+
return pairs
|
|
286
|
+
.map((p, i) => {
|
|
287
|
+
const accountsA = p.a.account_names.length > 0
|
|
288
|
+
? p.a.account_names.map(n => sanitizeForPrompt(n)).join(", ")
|
|
289
|
+
: "(no postings)";
|
|
290
|
+
const accountsB = p.b.account_names.length > 0
|
|
291
|
+
? p.b.account_names.map(n => sanitizeForPrompt(n)).join(", ")
|
|
292
|
+
: "(no postings)";
|
|
293
|
+
return [
|
|
294
|
+
`Pair ${i + 1} — ${formatAmount(p.amount)} ${p.currency} (gap ${p.day_gap} day${p.day_gap === 1 ? "" : "s"})`,
|
|
295
|
+
` A: ${p.a.date} "${sanitizeForPrompt(p.a.description)}" — ${accountsA} [${p.a.id}]`,
|
|
296
|
+
` B: ${p.b.date} "${sanitizeForPrompt(p.b.description)}" — ${accountsB} [${p.b.id}]`,
|
|
297
|
+
].join("\n");
|
|
298
|
+
})
|
|
299
|
+
.join("\n\n");
|
|
300
|
+
}
|
|
301
|
+
case "find_recurrences": {
|
|
302
|
+
const candidates = findRecurrenceCandidates(db, {
|
|
303
|
+
accountId: input.account_id,
|
|
304
|
+
minOccurrences: input.min_occurrences,
|
|
305
|
+
});
|
|
306
|
+
if (candidates.length === 0)
|
|
307
|
+
return "No recurrence candidates found.";
|
|
308
|
+
return candidates
|
|
309
|
+
.map((c, i) => {
|
|
310
|
+
const dates = c.transactions.map(e => e.date).join(", ");
|
|
311
|
+
const ids = c.transactions.map(e => e.id).join(", ");
|
|
312
|
+
return [
|
|
313
|
+
`Candidate ${i + 1} — ${formatAmount(c.amount)} ${c.currency} on ${sanitizeForPrompt(c.account_name)} (${c.side})`,
|
|
314
|
+
` Sightings (${c.transactions.length}): ${dates}`,
|
|
315
|
+
` Median gap: ${c.median_days_between} day(s) → implied ${c.implied_frequency}`,
|
|
316
|
+
` Transaction ids: ${ids}`,
|
|
317
|
+
].join("\n");
|
|
318
|
+
})
|
|
319
|
+
.join("\n\n");
|
|
320
|
+
}
|
|
321
|
+
case "record_recurrence": {
|
|
322
|
+
if (ctx?.dryRun)
|
|
323
|
+
return `Would record ${input.frequency} recurrence "${input.description}" linking ${(input.transaction_ids || []).length} transactions on ${input.account_id}.`;
|
|
324
|
+
try {
|
|
325
|
+
const id = recordRecurrence(db, {
|
|
326
|
+
account_id: input.account_id,
|
|
327
|
+
description: input.description,
|
|
328
|
+
frequency: input.frequency,
|
|
329
|
+
amount_typical: input.amount_typical ?? null,
|
|
330
|
+
currency: input.currency,
|
|
331
|
+
transaction_ids: input.transaction_ids || [],
|
|
332
|
+
notes: input.notes ?? null,
|
|
333
|
+
});
|
|
334
|
+
return `Recorded recurrence ${id} ("${sanitizeForPrompt(input.description)}", ${input.frequency}); linked ${(input.transaction_ids || []).length} transaction(s).`;
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
return `Could not record recurrence: ${err.message}`;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
case "link_transaction_to_recurrence": {
|
|
341
|
+
if (ctx?.dryRun)
|
|
342
|
+
return `Would link transaction ${input.transaction_id} → recurrence ${input.recurrence_id}.`;
|
|
343
|
+
try {
|
|
344
|
+
linkTransactionToRecurrence(db, input.transaction_id, input.recurrence_id);
|
|
345
|
+
return `Linked transaction ${input.transaction_id} → recurrence ${input.recurrence_id}.`;
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
return `Could not link: ${err.message}`;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
case "mark_review_done": {
|
|
352
|
+
ctx?.onComplete?.(input.summary || "");
|
|
353
|
+
return `Review complete. Summary: ${sanitizeForPrompt(input.summary || "")}`;
|
|
354
|
+
}
|
|
355
|
+
default:
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
export const reviewTools = { DEFS, LABELS, execute };
|
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: {
|
|
@@ -18,7 +18,9 @@ const LABELS = {
|
|
|
18
18
|
async function execute(_db, name, input, ctx) {
|
|
19
19
|
if (name !== "mark_file_scanned")
|
|
20
20
|
return undefined;
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
const summary = input.summary || "";
|
|
22
|
+
ctx?.buffer?.markDone(summary);
|
|
23
|
+
ctx?.onComplete?.(summary);
|
|
24
|
+
return `Marked file as scanned. Summary: ${sanitizeForPrompt(summary)}`;
|
|
23
25
|
}
|
|
24
26
|
export const scanTools = { DEFS, LABELS, execute };
|
package/dist/ai/tools/types.d.ts
CHANGED
|
@@ -1,17 +1,46 @@
|
|
|
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" | "record";
|
|
5
|
+
/**
|
|
6
|
+
* Structured highlights the review agent can pass to ask_user. The prompter
|
|
7
|
+
* renders them as a single colored header line above the question (each
|
|
8
|
+
* category gets its own chalk color), so the user can scan amount / date /
|
|
9
|
+
* merchant / accounts at a glance without parsing prose.
|
|
10
|
+
*/
|
|
11
|
+
export interface PromptUserFacts {
|
|
12
|
+
amount?: string;
|
|
13
|
+
date?: string;
|
|
14
|
+
merchant?: string;
|
|
15
|
+
accounts?: string[];
|
|
16
|
+
}
|
|
4
17
|
export interface AgentExecutionContext {
|
|
5
|
-
/** Set during scan so `
|
|
18
|
+
/** Set during scan so `record_transaction` can stamp `source_file_id`. */
|
|
6
19
|
fileId?: string;
|
|
7
20
|
/** When false, ask_user returns a marker and the caller halts after the run. */
|
|
8
21
|
interactive: boolean;
|
|
9
22
|
/** When true, mutating tools become no-ops that return a "would do X" preview. */
|
|
10
23
|
dryRun?: boolean;
|
|
11
24
|
/** Synchronously prompt the user (only invoked when interactive === true). */
|
|
12
|
-
promptUser?: (prompt: string, options?: string[]) => Promise<string>;
|
|
13
|
-
/** Called when the model declares the session is done (scan or
|
|
25
|
+
promptUser?: (prompt: string, options?: string[], facts?: PromptUserFacts) => Promise<string>;
|
|
26
|
+
/** Called when the model declares the session is done (scan or review). */
|
|
14
27
|
onComplete?: (summary: string) => void;
|
|
28
|
+
/**
|
|
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.
|
|
42
|
+
*/
|
|
43
|
+
buffer?: BufferedWriteContext;
|
|
15
44
|
}
|
|
16
45
|
/**
|
|
17
46
|
* A tool module owns a slice of tool definitions, the spinner labels that go
|
|
@@ -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}`);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { runReview } from "../../reviewer/pipeline.js";
|
|
3
|
+
export async function runReviewCommand(opts) {
|
|
4
|
+
try {
|
|
5
|
+
const result = await runReview(opts);
|
|
6
|
+
if (result.summary) {
|
|
7
|
+
console.log("");
|
|
8
|
+
console.log(chalk.bold(result.summary));
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
catch (err) {
|
|
12
|
+
console.error(chalk.red(`Review failed: ${err.message}`));
|
|
13
|
+
process.exitCode = 1;
|
|
14
|
+
}
|
|
15
|
+
}
|