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
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { upsertMerchant } from "./merchants.js";
|
|
3
|
+
const TOLERANCE = 0.005;
|
|
4
|
+
/**
|
|
5
|
+
* Insert a balanced transaction. Throws if SUM(debit) !== SUM(credit) or any
|
|
6
|
+
* posting both debits and credits. Transaction-wrapped: postings never land
|
|
7
|
+
* without a header, header never lands without postings.
|
|
8
|
+
*/
|
|
9
|
+
export function recordTransaction(db, input) {
|
|
10
|
+
const validated = validateTransaction(input);
|
|
11
|
+
const tx = db.transaction(() => { insertTransactionRows(db, validated); });
|
|
12
|
+
tx();
|
|
13
|
+
return validated.id;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Validate balance + invariants and assign an id. Pure (no DB writes). Used by
|
|
17
|
+
* both `recordTransaction` and the buffered-scan commit path; the latter
|
|
18
|
+
* already runs inside its own transaction and must not open another.
|
|
19
|
+
*/
|
|
20
|
+
export function validateTransaction(input) {
|
|
21
|
+
if (!input.postings || input.postings.length < 2) {
|
|
22
|
+
throw new Error("Transaction must contain at least two postings.");
|
|
23
|
+
}
|
|
24
|
+
let debitTotal = 0;
|
|
25
|
+
let creditTotal = 0;
|
|
26
|
+
for (const p of input.postings) {
|
|
27
|
+
const debit = p.debit ?? 0;
|
|
28
|
+
const credit = p.credit ?? 0;
|
|
29
|
+
if (debit < 0 || credit < 0) {
|
|
30
|
+
throw new Error("debit and credit values must be non-negative.");
|
|
31
|
+
}
|
|
32
|
+
if (debit > 0 && credit > 0) {
|
|
33
|
+
throw new Error("A single posting cannot debit and credit at the same time.");
|
|
34
|
+
}
|
|
35
|
+
if (debit === 0 && credit === 0) {
|
|
36
|
+
throw new Error("Each posting must have either a debit or a credit.");
|
|
37
|
+
}
|
|
38
|
+
debitTotal += debit;
|
|
39
|
+
creditTotal += credit;
|
|
40
|
+
}
|
|
41
|
+
if (Math.abs(debitTotal - creditTotal) > TOLERANCE) {
|
|
42
|
+
throw new Error(`Transaction does not balance: debits ${debitTotal.toFixed(2)} vs credits ${creditTotal.toFixed(2)}.`);
|
|
43
|
+
}
|
|
44
|
+
return { ...input, id: input.id ?? `tx:${randomUUID()}` };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Insert-only counterpart to `recordTransaction`. The caller is responsible
|
|
48
|
+
* for opening a transaction (or for accepting partial writes). Expects an
|
|
49
|
+
* already-validated input from `validateTransaction`.
|
|
50
|
+
*/
|
|
51
|
+
export function insertTransactionRows(db, input) {
|
|
52
|
+
let merchantId = input.merchant_id ?? null;
|
|
53
|
+
if (!merchantId && input.merchant) {
|
|
54
|
+
merchantId = upsertMerchant(db, input.merchant).id;
|
|
55
|
+
}
|
|
56
|
+
db.prepare(`INSERT INTO transactions (id, date, description, merchant_id, raw_descriptor, source_file_id, source_page)
|
|
57
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(input.id, input.date, input.description, merchantId, input.raw_descriptor ?? null, input.source_file_id ?? null, input.source_page ?? null);
|
|
58
|
+
const insertPosting = db.prepare(`INSERT INTO postings (id, transaction_id, account_id, debit, credit, currency, memo, pii_flag)
|
|
59
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
60
|
+
for (const p of input.postings) {
|
|
61
|
+
insertPosting.run(`p:${randomUUID()}`, input.id, p.account_id, p.debit ?? 0, p.credit ?? 0, p.currency || "THB", p.memo ?? null, p.pii_flag ? 1 : 0);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function updateTransaction(db, transactionId, fields) {
|
|
65
|
+
const sets = [];
|
|
66
|
+
const params = [];
|
|
67
|
+
if (fields.date !== undefined) {
|
|
68
|
+
sets.push("date = ?");
|
|
69
|
+
params.push(fields.date);
|
|
70
|
+
}
|
|
71
|
+
if (fields.description !== undefined) {
|
|
72
|
+
sets.push("description = ?");
|
|
73
|
+
params.push(fields.description);
|
|
74
|
+
}
|
|
75
|
+
if (fields.source_page !== undefined) {
|
|
76
|
+
sets.push("source_page = ?");
|
|
77
|
+
params.push(fields.source_page);
|
|
78
|
+
}
|
|
79
|
+
if (sets.length === 0)
|
|
80
|
+
return 0;
|
|
81
|
+
params.push(transactionId);
|
|
82
|
+
return db.prepare(`UPDATE transactions SET ${sets.join(", ")} WHERE id = ?`).run(...params).changes;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Safe single-posting edits only. Refuses changes to `debit`, `credit`, or `currency`
|
|
86
|
+
* because those would silently break the transaction's balance — to fix amounts the
|
|
87
|
+
* caller must delete the transaction and record a fresh one.
|
|
88
|
+
*/
|
|
89
|
+
export function updatePosting(db, postingId, fields) {
|
|
90
|
+
const sets = [];
|
|
91
|
+
const params = [];
|
|
92
|
+
if (fields.account_id !== undefined) {
|
|
93
|
+
sets.push("account_id = ?");
|
|
94
|
+
params.push(fields.account_id);
|
|
95
|
+
}
|
|
96
|
+
if (fields.memo !== undefined) {
|
|
97
|
+
sets.push("memo = ?");
|
|
98
|
+
params.push(fields.memo);
|
|
99
|
+
}
|
|
100
|
+
if (sets.length === 0)
|
|
101
|
+
return 0;
|
|
102
|
+
params.push(postingId);
|
|
103
|
+
return db.prepare(`UPDATE postings SET ${sets.join(", ")} WHERE id = ?`).run(...params).changes;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Delete a transaction. ON DELETE CASCADE on `postings.transaction_id` removes
|
|
107
|
+
* the postings automatically.
|
|
108
|
+
*/
|
|
109
|
+
export function deleteTransaction(db, transactionId) {
|
|
110
|
+
return db.prepare(`DELETE FROM transactions WHERE id = ?`).run(transactionId).changes;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Heuristic duplicate finder: group transactions by (rounded total debit) and check
|
|
114
|
+
* pairs whose date difference is ≤ toleranceDays. Returns groups with ≥2 members.
|
|
115
|
+
* Each transaction carries both account_ids (for follow-up tool calls) and
|
|
116
|
+
* account_names (for human-readable presentation to the user).
|
|
117
|
+
*/
|
|
118
|
+
export function findDuplicateTransactions(db, opts = {}) {
|
|
119
|
+
const toleranceDays = Math.max(0, Math.floor(opts.toleranceDays ?? 2));
|
|
120
|
+
const minAmount = opts.minAmount ?? 0;
|
|
121
|
+
const accountFilter = opts.accountId
|
|
122
|
+
? `WHERE t.id IN (SELECT transaction_id FROM postings WHERE account_id = ?)`
|
|
123
|
+
: ``;
|
|
124
|
+
const params = opts.accountId ? [opts.accountId] : [];
|
|
125
|
+
const nameById = loadAccountNames(db);
|
|
126
|
+
const rows = db.prepare(`SELECT t.id, t.date, t.description,
|
|
127
|
+
COALESCE(SUM(p.debit), 0) AS amount,
|
|
128
|
+
GROUP_CONCAT(p.account_id) AS account_ids
|
|
129
|
+
FROM transactions t
|
|
130
|
+
LEFT JOIN postings p ON p.transaction_id = t.id
|
|
131
|
+
${accountFilter}
|
|
132
|
+
GROUP BY t.id`).all(...params);
|
|
133
|
+
const candidates = rows
|
|
134
|
+
.filter(r => r.amount >= minAmount)
|
|
135
|
+
.map(r => {
|
|
136
|
+
const ids = (r.account_ids ?? "").split(",").filter(Boolean);
|
|
137
|
+
return {
|
|
138
|
+
id: r.id,
|
|
139
|
+
date: r.date,
|
|
140
|
+
description: r.description,
|
|
141
|
+
amount: Math.round(r.amount * 100) / 100,
|
|
142
|
+
account_ids: ids,
|
|
143
|
+
account_names: ids.map(id => nameById.get(id) ?? id),
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
const byAmount = new Map();
|
|
147
|
+
for (const e of candidates) {
|
|
148
|
+
const key = Math.round(e.amount * 100);
|
|
149
|
+
const arr = byAmount.get(key) ?? [];
|
|
150
|
+
arr.push(e);
|
|
151
|
+
byAmount.set(key, arr);
|
|
152
|
+
}
|
|
153
|
+
const groups = [];
|
|
154
|
+
for (const arr of byAmount.values()) {
|
|
155
|
+
if (arr.length < 2)
|
|
156
|
+
continue;
|
|
157
|
+
arr.sort((a, b) => a.date.localeCompare(b.date));
|
|
158
|
+
let current = [];
|
|
159
|
+
for (const e of arr) {
|
|
160
|
+
if (current.length === 0) {
|
|
161
|
+
current.push(e);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const last = current[current.length - 1];
|
|
165
|
+
if (dayDiff(last.date, e.date) <= toleranceDays) {
|
|
166
|
+
current.push(e);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
if (current.length >= 2)
|
|
170
|
+
groups.push(current);
|
|
171
|
+
current = [e];
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (current.length >= 2)
|
|
175
|
+
groups.push(current);
|
|
176
|
+
}
|
|
177
|
+
return groups;
|
|
178
|
+
}
|
|
179
|
+
export function dayDiff(a, b) {
|
|
180
|
+
const aDate = Date.parse(a);
|
|
181
|
+
const bDate = Date.parse(b);
|
|
182
|
+
if (Number.isNaN(aDate) || Number.isNaN(bDate))
|
|
183
|
+
return Number.POSITIVE_INFINITY;
|
|
184
|
+
return Math.abs(Math.round((bDate - aDate) / 86_400_000));
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Load all account id → name pairs into an in-memory map. Cheap on the small
|
|
188
|
+
* charts of accounts Plasalid deals with, and avoids GROUP_CONCAT join hacks
|
|
189
|
+
* (account names can contain commas which break a comma-separated concat, and
|
|
190
|
+
* SQLite's GROUP_CONCAT has no robust escape mechanism).
|
|
191
|
+
*/
|
|
192
|
+
function loadAccountNames(db) {
|
|
193
|
+
const map = new Map();
|
|
194
|
+
for (const row of db.prepare(`SELECT id, name FROM accounts`).all()) {
|
|
195
|
+
map.set(row.id, row.name);
|
|
196
|
+
}
|
|
197
|
+
return map;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Heuristic: surface pairs of transactions that look like the same money movement
|
|
201
|
+
* recorded against different accounts (e.g. a bank-to-card transfer that lands
|
|
202
|
+
* once on the bank statement and again on the card statement). Filters out
|
|
203
|
+
* pairs whose account-id sets overlap (those are duplicates, not correlations).
|
|
204
|
+
*/
|
|
205
|
+
export function findCorrelatedTransactions(db, opts = {}) {
|
|
206
|
+
const toleranceDays = Math.max(0, Math.floor(opts.toleranceDays ?? 3));
|
|
207
|
+
const minAmount = opts.minAmount ?? 0;
|
|
208
|
+
const dateFilter = [];
|
|
209
|
+
const params = [];
|
|
210
|
+
if (opts.from) {
|
|
211
|
+
dateFilter.push("t.date >= ?");
|
|
212
|
+
params.push(opts.from);
|
|
213
|
+
}
|
|
214
|
+
if (opts.to) {
|
|
215
|
+
dateFilter.push("t.date <= ?");
|
|
216
|
+
params.push(opts.to);
|
|
217
|
+
}
|
|
218
|
+
const where = dateFilter.length ? `WHERE ${dateFilter.join(" AND ")}` : "";
|
|
219
|
+
const nameById = loadAccountNames(db);
|
|
220
|
+
const rows = db.prepare(`SELECT t.id, t.date, t.description,
|
|
221
|
+
COALESCE(SUM(p.debit), 0) AS amount,
|
|
222
|
+
COALESCE(MAX(p.currency), 'THB') AS currency,
|
|
223
|
+
GROUP_CONCAT(p.account_id) AS account_ids
|
|
224
|
+
FROM transactions t
|
|
225
|
+
LEFT JOIN postings p ON p.transaction_id = t.id
|
|
226
|
+
${where}
|
|
227
|
+
GROUP BY t.id`).all(...params);
|
|
228
|
+
const candidates = rows
|
|
229
|
+
.filter(r => r.amount >= minAmount)
|
|
230
|
+
.map(r => {
|
|
231
|
+
const ids = (r.account_ids ?? "").split(",").filter(Boolean);
|
|
232
|
+
return {
|
|
233
|
+
id: r.id,
|
|
234
|
+
date: r.date,
|
|
235
|
+
description: r.description,
|
|
236
|
+
amount: Math.round(r.amount * 100) / 100,
|
|
237
|
+
currency: r.currency || "THB",
|
|
238
|
+
account_ids: ids,
|
|
239
|
+
account_names: ids.map(id => nameById.get(id) ?? id),
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
return correlatePairs(candidates, { toleranceDays });
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Pure pair-finder: given an array of candidates already filtered by amount
|
|
246
|
+
* and equipped with account_ids/names, return the cross-pairs that look like
|
|
247
|
+
* the same money movement on different accounts (date within toleranceDays,
|
|
248
|
+
* same amount + currency, non-overlapping account sets).
|
|
249
|
+
*
|
|
250
|
+
* Used by the DB-backed `findCorrelatedTransactions` and by the scan-time
|
|
251
|
+
* coordinator that runs over buffered, not-yet-committed transactions.
|
|
252
|
+
*/
|
|
253
|
+
export function correlatePairs(candidates, opts = {}) {
|
|
254
|
+
const toleranceDays = Math.max(0, Math.floor(opts.toleranceDays ?? 3));
|
|
255
|
+
const buckets = new Map();
|
|
256
|
+
for (const e of candidates) {
|
|
257
|
+
const key = `${Math.round(e.amount * 100)}|${e.currency}`;
|
|
258
|
+
const arr = buckets.get(key) ?? [];
|
|
259
|
+
arr.push(e);
|
|
260
|
+
buckets.set(key, arr);
|
|
261
|
+
}
|
|
262
|
+
const pairs = [];
|
|
263
|
+
for (const bucket of buckets.values()) {
|
|
264
|
+
if (bucket.length < 2)
|
|
265
|
+
continue;
|
|
266
|
+
bucket.sort((x, y) => x.date.localeCompare(y.date));
|
|
267
|
+
for (let i = 0; i < bucket.length; i++) {
|
|
268
|
+
for (let j = i + 1; j < bucket.length; j++) {
|
|
269
|
+
const a = bucket[i], b = bucket[j];
|
|
270
|
+
const gap = dayDiff(a.date, b.date);
|
|
271
|
+
if (gap > toleranceDays)
|
|
272
|
+
break;
|
|
273
|
+
const overlap = a.account_ids.some(id => b.account_ids.includes(id));
|
|
274
|
+
if (overlap)
|
|
275
|
+
continue;
|
|
276
|
+
pairs.push({
|
|
277
|
+
amount: a.amount,
|
|
278
|
+
currency: a.currency,
|
|
279
|
+
day_gap: gap,
|
|
280
|
+
a: { id: a.id, date: a.date, description: a.description, account_ids: a.account_ids, account_names: a.account_names },
|
|
281
|
+
b: { id: b.id, date: b.date, description: b.description, account_ids: b.account_ids, account_names: b.account_names },
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return pairs;
|
|
287
|
+
}
|
|
288
|
+
export function listPostings(db, opts = {}) {
|
|
289
|
+
const conditions = [];
|
|
290
|
+
const params = [];
|
|
291
|
+
if (opts.account_id) {
|
|
292
|
+
conditions.push("p.account_id = ?");
|
|
293
|
+
params.push(opts.account_id);
|
|
294
|
+
}
|
|
295
|
+
if (opts.from) {
|
|
296
|
+
conditions.push("t.date >= ?");
|
|
297
|
+
params.push(opts.from);
|
|
298
|
+
}
|
|
299
|
+
if (opts.to) {
|
|
300
|
+
conditions.push("t.date <= ?");
|
|
301
|
+
params.push(opts.to);
|
|
302
|
+
}
|
|
303
|
+
if (opts.q) {
|
|
304
|
+
conditions.push("(t.description LIKE ? OR p.memo LIKE ? OR m.canonical_name LIKE ?)");
|
|
305
|
+
params.push(`%${opts.q}%`, `%${opts.q}%`, `%${opts.q}%`);
|
|
306
|
+
}
|
|
307
|
+
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
308
|
+
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 500);
|
|
309
|
+
return db.prepare(`SELECT p.id, p.transaction_id, p.account_id, p.debit, p.credit, p.currency, p.memo,
|
|
310
|
+
a.name AS account_name, a.type AS account_type,
|
|
311
|
+
t.date AS transaction_date, t.description AS transaction_description,
|
|
312
|
+
m.canonical_name AS merchant_name
|
|
313
|
+
FROM postings p
|
|
314
|
+
JOIN transactions t ON t.id = p.transaction_id
|
|
315
|
+
JOIN accounts a ON a.id = p.account_id
|
|
316
|
+
LEFT JOIN merchants m ON m.id = t.merchant_id
|
|
317
|
+
${where}
|
|
318
|
+
ORDER BY t.date DESC, t.id DESC
|
|
319
|
+
LIMIT ?`).all(...params, limit);
|
|
320
|
+
}
|
package/dist/db/schema.js
CHANGED
|
@@ -4,6 +4,7 @@ export function migrate(db) {
|
|
|
4
4
|
id TEXT PRIMARY KEY,
|
|
5
5
|
name TEXT NOT NULL,
|
|
6
6
|
type TEXT NOT NULL CHECK(type IN ('asset','liability','income','expense','equity')),
|
|
7
|
+
parent_id TEXT REFERENCES accounts(id),
|
|
7
8
|
subtype TEXT,
|
|
8
9
|
bank_name TEXT,
|
|
9
10
|
account_number_masked TEXT,
|
|
@@ -17,6 +18,25 @@ export function migrate(db) {
|
|
|
17
18
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
18
19
|
);
|
|
19
20
|
|
|
21
|
+
CREATE INDEX IF NOT EXISTS accounts_parent_idx ON accounts(parent_id);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS accounts_type_idx ON accounts(type);
|
|
23
|
+
|
|
24
|
+
CREATE TABLE IF NOT EXISTS merchants (
|
|
25
|
+
id TEXT PRIMARY KEY,
|
|
26
|
+
canonical_name TEXT NOT NULL UNIQUE,
|
|
27
|
+
default_account_id TEXT REFERENCES accounts(id),
|
|
28
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS merchant_aliases (
|
|
32
|
+
id TEXT PRIMARY KEY,
|
|
33
|
+
merchant_id TEXT NOT NULL REFERENCES merchants(id) ON DELETE CASCADE,
|
|
34
|
+
normalized_pattern TEXT NOT NULL UNIQUE,
|
|
35
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE INDEX IF NOT EXISTS merchant_aliases_merchant_idx ON merchant_aliases(merchant_id);
|
|
39
|
+
|
|
20
40
|
CREATE TABLE IF NOT EXISTS scanned_files (
|
|
21
41
|
id TEXT PRIMARY KEY,
|
|
22
42
|
path TEXT NOT NULL,
|
|
@@ -45,10 +65,12 @@ export function migrate(db) {
|
|
|
45
65
|
|
|
46
66
|
CREATE INDEX IF NOT EXISTS recurrences_account_idx ON recurrences(account_id);
|
|
47
67
|
|
|
48
|
-
CREATE TABLE IF NOT EXISTS
|
|
68
|
+
CREATE TABLE IF NOT EXISTS transactions (
|
|
49
69
|
id TEXT PRIMARY KEY,
|
|
50
70
|
date TEXT NOT NULL,
|
|
51
71
|
description TEXT NOT NULL,
|
|
72
|
+
merchant_id TEXT REFERENCES merchants(id),
|
|
73
|
+
raw_descriptor TEXT,
|
|
52
74
|
source_file_id TEXT REFERENCES scanned_files(id) ON DELETE CASCADE,
|
|
53
75
|
source_page INTEGER,
|
|
54
76
|
recurrence_id TEXT REFERENCES recurrences(id) ON DELETE SET NULL,
|
|
@@ -56,11 +78,14 @@ export function migrate(db) {
|
|
|
56
78
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
57
79
|
);
|
|
58
80
|
|
|
59
|
-
CREATE INDEX IF NOT EXISTS
|
|
81
|
+
CREATE INDEX IF NOT EXISTS transactions_recurrence_idx ON transactions(recurrence_id);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS transactions_source_file_idx ON transactions(source_file_id);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS transactions_date_idx ON transactions(date);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS transactions_merchant_idx ON transactions(merchant_id);
|
|
60
85
|
|
|
61
|
-
CREATE TABLE IF NOT EXISTS
|
|
86
|
+
CREATE TABLE IF NOT EXISTS postings (
|
|
62
87
|
id TEXT PRIMARY KEY,
|
|
63
|
-
|
|
88
|
+
transaction_id TEXT NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
|
|
64
89
|
account_id TEXT NOT NULL REFERENCES accounts(id),
|
|
65
90
|
debit REAL NOT NULL DEFAULT 0,
|
|
66
91
|
credit REAL NOT NULL DEFAULT 0,
|
|
@@ -70,16 +95,15 @@ export function migrate(db) {
|
|
|
70
95
|
CHECK (debit >= 0 AND credit >= 0 AND (debit = 0 OR credit = 0))
|
|
71
96
|
);
|
|
72
97
|
|
|
73
|
-
CREATE INDEX IF NOT EXISTS
|
|
74
|
-
CREATE INDEX IF NOT EXISTS
|
|
75
|
-
CREATE INDEX IF NOT EXISTS journal_entries_source_file_idx ON journal_entries(source_file_id);
|
|
76
|
-
CREATE INDEX IF NOT EXISTS journal_entries_date_idx ON journal_entries(date);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS postings_transaction_idx ON postings(transaction_id);
|
|
99
|
+
CREATE INDEX IF NOT EXISTS postings_account_idx ON postings(account_id);
|
|
77
100
|
|
|
78
101
|
CREATE TABLE IF NOT EXISTS concerns (
|
|
79
102
|
id TEXT PRIMARY KEY,
|
|
80
103
|
file_id TEXT REFERENCES scanned_files(id) ON DELETE CASCADE,
|
|
81
|
-
|
|
104
|
+
transaction_id TEXT REFERENCES transactions(id) ON DELETE CASCADE,
|
|
82
105
|
account_id TEXT REFERENCES accounts(id) ON DELETE CASCADE,
|
|
106
|
+
kind TEXT,
|
|
83
107
|
prompt TEXT NOT NULL,
|
|
84
108
|
options_json TEXT,
|
|
85
109
|
answer TEXT,
|
|
@@ -114,5 +138,23 @@ export function migrate(db) {
|
|
|
114
138
|
use_count INTEGER NOT NULL DEFAULT 0,
|
|
115
139
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
116
140
|
);
|
|
141
|
+
|
|
142
|
+
CREATE TABLE IF NOT EXISTS action_log (
|
|
143
|
+
id TEXT PRIMARY KEY,
|
|
144
|
+
correlation_id TEXT NOT NULL,
|
|
145
|
+
command TEXT NOT NULL,
|
|
146
|
+
user_input TEXT,
|
|
147
|
+
action_type TEXT NOT NULL CHECK(action_type IN (
|
|
148
|
+
'create_account','update_account_metadata','record_transaction','adjust_balance',
|
|
149
|
+
'create_merchant','update_merchant_default'
|
|
150
|
+
)),
|
|
151
|
+
target_id TEXT NOT NULL,
|
|
152
|
+
payload_json TEXT NOT NULL,
|
|
153
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
154
|
+
reverted_at TEXT
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
CREATE INDEX IF NOT EXISTS action_log_correlation_idx ON action_log(correlation_id);
|
|
158
|
+
CREATE INDEX IF NOT EXISTS action_log_created_idx ON action_log(created_at);
|
|
117
159
|
`);
|
|
118
160
|
}
|
|
@@ -10,9 +10,9 @@ export interface ReviewSummary {
|
|
|
10
10
|
dryRun: boolean;
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
|
-
* Walk the existing
|
|
14
|
-
* concerns, detect correlated transactions and
|
|
15
|
-
* apply them (or print "would do X" stubs when
|
|
16
|
-
* confirms one step at a time.
|
|
13
|
+
* Walk the existing ledger with the review-profile agent: surface open
|
|
14
|
+
* concerns (uncategorized cleanup first), detect correlated transactions and
|
|
15
|
+
* recurrences, propose fixes, apply them (or print "would do X" stubs when
|
|
16
|
+
* dryRun is on) after the user confirms one step at a time.
|
|
17
17
|
*/
|
|
18
18
|
export declare function runReview(opts?: ReviewOptions): Promise<ReviewSummary>;
|
|
@@ -3,10 +3,10 @@ import { runReviewAgent } from "../ai/agent.js";
|
|
|
3
3
|
import { statusSpinner, makePromptUser, makeAgentOnProgress, } from "../cli/ux.js";
|
|
4
4
|
import { buildReviewUserMessage } from "./prompts.js";
|
|
5
5
|
/**
|
|
6
|
-
* Walk the existing
|
|
7
|
-
* concerns, detect correlated transactions and
|
|
8
|
-
* apply them (or print "would do X" stubs when
|
|
9
|
-
* confirms one step at a time.
|
|
6
|
+
* Walk the existing ledger with the review-profile agent: surface open
|
|
7
|
+
* concerns (uncategorized cleanup first), detect correlated transactions and
|
|
8
|
+
* recurrences, propose fixes, apply them (or print "would do X" stubs when
|
|
9
|
+
* dryRun is on) after the user confirms one step at a time.
|
|
10
10
|
*/
|
|
11
11
|
export async function runReview(opts = {}) {
|
|
12
12
|
const db = getDb();
|
package/dist/reviewer/prompts.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export function buildReviewUserMessage(scope) {
|
|
7
7
|
return [
|
|
8
|
-
`Review the local Plasalid
|
|
8
|
+
`Review the local Plasalid ledger.`,
|
|
9
9
|
``,
|
|
10
10
|
`Scope:`,
|
|
11
11
|
`- account: ${scope.accountId ?? "all"}`,
|
|
@@ -14,9 +14,9 @@ export function buildReviewUserMessage(scope) {
|
|
|
14
14
|
`- dry run: ${scope.dryRun ? "yes — write tools are no-ops" : "no — writes commit after confirmation"}`,
|
|
15
15
|
``,
|
|
16
16
|
`Steps:`,
|
|
17
|
-
`1. Survey first: list_accounts, get_net_worth, count open concerns, then
|
|
18
|
-
`2. Prioritize open concerns
|
|
19
|
-
`3. Ask one focused question at a time via ask_user. After each answer, apply the change and re-survey only if the change invalidated other candidates.`,
|
|
17
|
+
`1. Survey first: list_accounts, get_net_worth, count open concerns (especially kind='uncategorized_expense'), then find_duplicate_transactions, find_similar_accounts, find_unused_accounts, find_correlated_transactions, find_recurrences. Hold the candidate list internally.`,
|
|
18
|
+
`2. Prioritize: (a) uncategorized expense cleanup — these are postings parked in expense:uncategorized awaiting a real category; resolving one should also call set_merchant_default_account when the transaction has a merchant, so future statements skip the categorizer. (b) other open concerns. (c) correlated transactions. (d) recurrences. (e) chart-of-accounts hygiene.`,
|
|
19
|
+
`3. Ask one focused question at a time via ask_user. Group sibling concerns (same merchant, same answer) via related_concern_ids so the user answers once. After each answer, apply the change and re-survey only if the change invalidated other candidates.`,
|
|
20
20
|
`4. Loop until no open concerns remain (or the user keeps choosing "Skip — leave as is"). Then call mark_review_done with a short summary of what was applied, recorded, and skipped.`,
|
|
21
21
|
].join("\n");
|
|
22
22
|
}
|
package/dist/scanner/buffer.d.ts
CHANGED
|
@@ -1,48 +1,51 @@
|
|
|
1
1
|
import type Database from "libsql";
|
|
2
|
-
import { type
|
|
2
|
+
import { type TransactionInput } from "../db/queries/transactions.js";
|
|
3
3
|
/**
|
|
4
|
-
* One scan agent's pending writes.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* One scan agent's pending writes. Transactions and concerns accumulate here
|
|
5
|
+
* while the LLM works; nothing hits the DB until `commit()` runs inside a
|
|
6
|
+
* single SQLite transaction. If `commit()` throws, the transaction rolls back
|
|
7
|
+
* and the DB stays exactly as it was before this file's scan began.
|
|
8
8
|
*
|
|
9
|
-
* Account writes (`create_account`, `update_account_metadata`)
|
|
10
|
-
* bypass the buffer — they go directly to the DB through
|
|
11
|
-
* concurrent agents see each other's
|
|
9
|
+
* Account writes (`create_account`, `update_account_metadata`) and merchant
|
|
10
|
+
* writes deliberately bypass the buffer — they go directly to the DB through
|
|
11
|
+
* their own mutexes so concurrent agents see each other's creates and don't
|
|
12
|
+
* duplicate.
|
|
12
13
|
*/
|
|
13
14
|
export interface BufferedConcern {
|
|
14
|
-
/** Synthesized when the LLM called note_concern with a buffered
|
|
15
|
-
|
|
15
|
+
/** Synthesized when the LLM called note_concern with a buffered transaction_id. */
|
|
16
|
+
transaction_id: string | null;
|
|
16
17
|
account_id: string | null;
|
|
18
|
+
kind?: string | null;
|
|
17
19
|
prompt: string;
|
|
18
20
|
options?: string[];
|
|
19
21
|
}
|
|
20
|
-
export interface
|
|
21
|
-
/** Synthesized at queue-time so concerns can reference this
|
|
22
|
-
|
|
23
|
-
input:
|
|
22
|
+
export interface BufferedTransaction {
|
|
23
|
+
/** Synthesized at queue-time so concerns can reference this transaction. */
|
|
24
|
+
transaction_id: string;
|
|
25
|
+
input: TransactionInput;
|
|
24
26
|
}
|
|
25
27
|
export declare class BufferedWriteContext {
|
|
26
28
|
readonly fileName: string;
|
|
27
|
-
readonly
|
|
29
|
+
readonly transactions: BufferedTransaction[];
|
|
28
30
|
readonly concerns: BufferedConcern[];
|
|
29
31
|
doneSummary: string | null;
|
|
30
32
|
constructor(fileName: string);
|
|
31
33
|
/**
|
|
32
|
-
* Queue a
|
|
33
|
-
* use it in subsequent note_concern calls inside the same file.
|
|
34
|
+
* Queue a transaction. Returns the synthesized transaction id so the agent
|
|
35
|
+
* can use it in subsequent note_concern calls inside the same file.
|
|
34
36
|
*/
|
|
35
|
-
|
|
37
|
+
appendTransaction(input: TransactionInput): string;
|
|
36
38
|
appendConcern(concern: BufferedConcern): void;
|
|
37
39
|
markDone(summary: string): void;
|
|
38
40
|
get isDone(): boolean;
|
|
39
41
|
/**
|
|
40
42
|
* Replay all buffered writes inside one DB transaction. `scannedFileId` is
|
|
41
|
-
* stamped onto every
|
|
42
|
-
* Returns `{
|
|
43
|
+
* stamped onto every transaction and concern so they're attributable to this
|
|
44
|
+
* file. Returns `{ transactions, concerns }` counts so the caller can report
|
|
45
|
+
* them.
|
|
43
46
|
*/
|
|
44
47
|
commit(db: Database.Database, scannedFileId: string): {
|
|
45
|
-
|
|
48
|
+
transactions: number;
|
|
46
49
|
concerns: number;
|
|
47
50
|
};
|
|
48
51
|
}
|
package/dist/scanner/buffer.js
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { insertTransactionRows, validateTransaction, } from "../db/queries/transactions.js";
|
|
3
3
|
import { recordConcern } from "../db/queries/concerns.js";
|
|
4
4
|
export class BufferedWriteContext {
|
|
5
5
|
fileName;
|
|
6
|
-
|
|
6
|
+
transactions = [];
|
|
7
7
|
concerns = [];
|
|
8
8
|
doneSummary = null;
|
|
9
9
|
constructor(fileName) {
|
|
10
10
|
this.fileName = fileName;
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
|
-
* Queue a
|
|
14
|
-
* use it in subsequent note_concern calls inside the same file.
|
|
13
|
+
* Queue a transaction. Returns the synthesized transaction id so the agent
|
|
14
|
+
* can use it in subsequent note_concern calls inside the same file.
|
|
15
15
|
*/
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
this.
|
|
19
|
-
return
|
|
16
|
+
appendTransaction(input) {
|
|
17
|
+
const transactionId = `tx:${randomUUID()}`;
|
|
18
|
+
this.transactions.push({ transaction_id: transactionId, input });
|
|
19
|
+
return transactionId;
|
|
20
20
|
}
|
|
21
21
|
appendConcern(concern) {
|
|
22
22
|
this.concerns.push(concern);
|
|
@@ -29,35 +29,35 @@ export class BufferedWriteContext {
|
|
|
29
29
|
}
|
|
30
30
|
/**
|
|
31
31
|
* Replay all buffered writes inside one DB transaction. `scannedFileId` is
|
|
32
|
-
* stamped onto every
|
|
33
|
-
* Returns `{
|
|
32
|
+
* stamped onto every transaction and concern so they're attributable to this
|
|
33
|
+
* file. Returns `{ transactions, concerns }` counts so the caller can report
|
|
34
|
+
* them.
|
|
34
35
|
*/
|
|
35
36
|
commit(db, scannedFileId) {
|
|
36
|
-
|
|
37
|
-
// the transaction (clean failure with no partial state to roll back).
|
|
38
|
-
const validated = this.journalEntries.map(b => ({
|
|
37
|
+
const validated = this.transactions.map(b => ({
|
|
39
38
|
buffered: b,
|
|
40
|
-
validated:
|
|
39
|
+
validated: validateTransaction({
|
|
41
40
|
...b.input,
|
|
42
|
-
id: b.
|
|
41
|
+
id: b.transaction_id,
|
|
43
42
|
source_file_id: scannedFileId,
|
|
44
43
|
}),
|
|
45
44
|
}));
|
|
46
45
|
const tx = db.transaction(() => {
|
|
47
46
|
for (const { validated: v } of validated) {
|
|
48
|
-
|
|
47
|
+
insertTransactionRows(db, v);
|
|
49
48
|
}
|
|
50
49
|
for (const c of this.concerns) {
|
|
51
50
|
recordConcern(db, {
|
|
52
51
|
file_id: scannedFileId,
|
|
53
|
-
|
|
52
|
+
transaction_id: c.transaction_id,
|
|
54
53
|
account_id: c.account_id,
|
|
54
|
+
kind: c.kind ?? null,
|
|
55
55
|
prompt: c.prompt,
|
|
56
56
|
options: c.options,
|
|
57
57
|
});
|
|
58
58
|
}
|
|
59
59
|
});
|
|
60
60
|
tx();
|
|
61
|
-
return {
|
|
61
|
+
return { transactions: this.transactions.length, concerns: this.concerns.length };
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -3,7 +3,7 @@ export interface ScanFileResult {
|
|
|
3
3
|
name: string;
|
|
4
4
|
relPath: string;
|
|
5
5
|
status: ScanFileStatus;
|
|
6
|
-
|
|
6
|
+
transactions: number;
|
|
7
7
|
concerns: number;
|
|
8
8
|
error?: string;
|
|
9
9
|
}
|
|
@@ -40,7 +40,7 @@ export interface ScanRunEvents {
|
|
|
40
40
|
scanEnd?: (e: {
|
|
41
41
|
fileName: string;
|
|
42
42
|
status: "scanned" | "failed";
|
|
43
|
-
|
|
43
|
+
transactions: number;
|
|
44
44
|
concerns: number;
|
|
45
45
|
error?: string;
|
|
46
46
|
}) => void;
|
|
@@ -57,4 +57,5 @@ export interface RunScanOptions {
|
|
|
57
57
|
events?: ScanRunEvents;
|
|
58
58
|
}
|
|
59
59
|
export declare function compileMatcher(input: string): RegExp;
|
|
60
|
+
/** Orchestration */
|
|
60
61
|
export declare function runScan(opts?: RunScanOptions): Promise<ScanSummary>;
|