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.
Files changed (65) hide show
  1. package/README.md +15 -14
  2. package/dist/ai/agent.d.ts +15 -2
  3. package/dist/ai/agent.js +21 -2
  4. package/dist/ai/memory.d.ts +2 -0
  5. package/dist/ai/memory.js +2 -2
  6. package/dist/ai/personas.d.ts +2 -1
  7. package/dist/ai/personas.js +115 -45
  8. package/dist/ai/prompt-sections.d.ts +5 -0
  9. package/dist/ai/prompt-sections.js +26 -8
  10. package/dist/ai/system-prompt.d.ts +11 -0
  11. package/dist/ai/system-prompt.js +21 -6
  12. package/dist/ai/thinking.js +1 -1
  13. package/dist/ai/tools/common.js +2 -5
  14. package/dist/ai/tools/index.js +28 -8
  15. package/dist/ai/tools/ingest.d.ts +2 -1
  16. package/dist/ai/tools/ingest.js +262 -151
  17. package/dist/ai/tools/merchants.d.ts +2 -0
  18. package/dist/ai/tools/merchants.js +117 -0
  19. package/dist/ai/tools/read.js +31 -29
  20. package/dist/ai/tools/record.d.ts +2 -0
  21. package/dist/ai/tools/record.js +188 -0
  22. package/dist/ai/tools/review.js +77 -80
  23. package/dist/ai/tools/scan.js +1 -1
  24. package/dist/ai/tools/types.d.ts +15 -6
  25. package/dist/cli/commands/accounts.js +33 -25
  26. package/dist/cli/commands/record.d.ts +4 -0
  27. package/dist/cli/commands/record.js +119 -0
  28. package/dist/cli/commands/revert.js +1 -1
  29. package/dist/cli/commands/scan.js +15 -19
  30. package/dist/cli/commands/status.js +6 -9
  31. package/dist/cli/commands/transactions.js +36 -41
  32. package/dist/cli/format.d.ts +2 -0
  33. package/dist/cli/format.js +7 -2
  34. package/dist/cli/index.js +19 -7
  35. package/dist/cli/ink/scan_dashboard.d.ts +1 -1
  36. package/dist/cli/ink/scan_dashboard.js +2 -2
  37. package/dist/cli/setup.d.ts +0 -1
  38. package/dist/cli/setup.js +2 -8
  39. package/dist/currency.d.ts +3 -0
  40. package/dist/currency.js +12 -1
  41. package/dist/db/queries/account_balance.d.ts +83 -4
  42. package/dist/db/queries/account_balance.js +239 -20
  43. package/dist/db/queries/action_log.d.ts +29 -0
  44. package/dist/db/queries/action_log.js +27 -0
  45. package/dist/db/queries/concerns.d.ts +10 -7
  46. package/dist/db/queries/concerns.js +20 -16
  47. package/dist/db/queries/journal.d.ts +1 -0
  48. package/dist/db/queries/merchants.d.ts +42 -0
  49. package/dist/db/queries/merchants.js +120 -0
  50. package/dist/db/queries/recurrences.d.ts +3 -3
  51. package/dist/db/queries/recurrences.js +32 -34
  52. package/dist/db/queries/search.d.ts +5 -4
  53. package/dist/db/queries/search.js +16 -12
  54. package/dist/db/queries/transactions.d.ts +167 -0
  55. package/dist/db/queries/transactions.js +320 -0
  56. package/dist/db/schema.js +51 -9
  57. package/dist/reviewer/pipeline.d.ts +4 -4
  58. package/dist/reviewer/pipeline.js +4 -4
  59. package/dist/reviewer/prompts.js +4 -4
  60. package/dist/scanner/buffer.d.ts +24 -21
  61. package/dist/scanner/buffer.js +18 -18
  62. package/dist/scanner/pipeline.d.ts +3 -2
  63. package/dist/scanner/pipeline.js +33 -36
  64. package/dist/scanner/prompts.js +3 -3
  65. 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 journal_entries (
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 journal_entries_recurrence_idx ON journal_entries(recurrence_id);
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 journal_lines (
86
+ CREATE TABLE IF NOT EXISTS postings (
62
87
  id TEXT PRIMARY KEY,
63
- entry_id TEXT NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
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 journal_lines_entry_idx ON journal_lines(entry_id);
74
- CREATE INDEX IF NOT EXISTS journal_lines_account_idx ON journal_lines(account_id);
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
- entry_id TEXT REFERENCES journal_entries(id) ON DELETE CASCADE,
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 journal with the review-profile agent: surface open
14
- * concerns, detect correlated transactions and recurrences, propose fixes,
15
- * apply them (or print "would do X" stubs when dryRun is on) after the user
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 journal with the review-profile agent: surface open
7
- * concerns, detect correlated transactions and recurrences, propose fixes,
8
- * apply them (or print "would do X" stubs when dryRun is on) after the user
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();
@@ -5,7 +5,7 @@
5
5
  */
6
6
  export function buildReviewUserMessage(scope) {
7
7
  return [
8
- `Review the local Plasalid journal.`,
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 find_duplicate_entries, find_similar_accounts, find_unused_accounts, find_correlated_entries, find_recurrences. Hold the candidate list internally.`,
18
- `2. Prioritize open concerns, then correlated transactions, then recurrences, then chart-of-accounts hygiene.`,
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
  }
@@ -1,48 +1,51 @@
1
1
  import type Database from "libsql";
2
- import { type JournalEntryInput } from "../db/queries/journal.js";
2
+ import { type TransactionInput } from "../db/queries/transactions.js";
3
3
  /**
4
- * One scan agent's pending writes. Journal entries and concerns accumulate
5
- * here while the LLM works; nothing hits the DB until `commit()` runs inside
6
- * a single SQLite transaction. If `commit()` throws, the transaction rolls
7
- * back and the DB stays exactly as it was before this file's scan began.
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`) deliberately
10
- * bypass the buffer — they go directly to the DB through `account_mutex` so
11
- * concurrent agents see each other's account creations and don't duplicate.
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 entry_id. */
15
- entry_id: string | null;
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 BufferedEntry {
21
- /** Synthesized at queue-time so concerns can reference this entry. */
22
- entry_id: string;
23
- input: JournalEntryInput;
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 journalEntries: BufferedEntry[];
29
+ readonly transactions: BufferedTransaction[];
28
30
  readonly concerns: BufferedConcern[];
29
31
  doneSummary: string | null;
30
32
  constructor(fileName: string);
31
33
  /**
32
- * Queue a journal entry. Returns the synthesized entry id so the agent can
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
- appendEntry(input: JournalEntryInput): string;
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 entry and concern so they're attributable to this file.
42
- * Returns `{ entries, concerns }` counts so the caller can report them.
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
- entries: number;
48
+ transactions: number;
46
49
  concerns: number;
47
50
  };
48
51
  }
@@ -1,22 +1,22 @@
1
1
  import { randomUUID } from "crypto";
2
- import { insertJournalEntryRows, validateJournalEntry, } from "../db/queries/journal.js";
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
- journalEntries = [];
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 journal entry. Returns the synthesized entry id so the agent can
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
- appendEntry(input) {
17
- const entryId = `je:${randomUUID()}`;
18
- this.journalEntries.push({ entry_id: entryId, input });
19
- return entryId;
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 entry and concern so they're attributable to this file.
33
- * Returns `{ entries, concerns }` counts so the caller can report them.
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
- // Validate all entries up-front so a balance error throws before we open
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: validateJournalEntry({
39
+ validated: validateTransaction({
41
40
  ...b.input,
42
- id: b.entry_id,
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
- insertJournalEntryRows(db, v);
47
+ insertTransactionRows(db, v);
49
48
  }
50
49
  for (const c of this.concerns) {
51
50
  recordConcern(db, {
52
51
  file_id: scannedFileId,
53
- entry_id: c.entry_id,
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 { entries: this.journalEntries.length, concerns: this.concerns.length };
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
- entries: number;
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
- entries: number;
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>;