plasalid 0.5.8 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +9 -9
  2. package/dist/accounts/taxonomy.d.ts +1 -1
  3. package/dist/accounts/taxonomy.js +2 -2
  4. package/dist/ai/agent.d.ts +7 -6
  5. package/dist/ai/agent.js +9 -8
  6. package/dist/ai/personas.d.ts +1 -1
  7. package/dist/ai/personas.js +69 -66
  8. package/dist/ai/prompt-sections.d.ts +4 -5
  9. package/dist/ai/prompt-sections.js +11 -11
  10. package/dist/ai/system-prompt.d.ts +2 -3
  11. package/dist/ai/system-prompt.js +5 -5
  12. package/dist/ai/tools/common.js +13 -5
  13. package/dist/ai/tools/index.js +15 -15
  14. package/dist/ai/tools/ingest.d.ts +2 -2
  15. package/dist/ai/tools/ingest.js +210 -87
  16. package/dist/ai/tools/merchants.js +27 -12
  17. package/dist/ai/tools/read.js +36 -20
  18. package/dist/ai/tools/record.js +79 -19
  19. package/dist/ai/tools/resolve.d.ts +2 -0
  20. package/dist/ai/tools/resolve.js +195 -0
  21. package/dist/ai/tools/types.d.ts +5 -7
  22. package/dist/cli/commands/accounts.js +2 -2
  23. package/dist/cli/commands/record.js +4 -2
  24. package/dist/cli/commands/resolve.d.ts +2 -0
  25. package/dist/cli/commands/resolve.js +13 -0
  26. package/dist/cli/commands/scan.js +18 -22
  27. package/dist/cli/commands/status.js +4 -2
  28. package/dist/cli/index.js +9 -9
  29. package/dist/cli/ink/hooks/useFooterText.js +1 -1
  30. package/dist/cli/ink/hooks/useTextInput.js +0 -3
  31. package/dist/cli/ink/scan_dashboard.d.ts +2 -2
  32. package/dist/cli/ink/scan_dashboard.js +3 -3
  33. package/dist/cli/setup.js +6 -3
  34. package/dist/cli/ux.js +1 -1
  35. package/dist/db/queries/account-balance.d.ts +140 -0
  36. package/dist/db/queries/account-balance.js +355 -0
  37. package/dist/db/queries/account_balance.d.ts +0 -1
  38. package/dist/db/queries/account_balance.js +0 -10
  39. package/dist/db/queries/action-log.d.ts +29 -0
  40. package/dist/db/queries/action-log.js +27 -0
  41. package/dist/db/queries/action_log.d.ts +1 -1
  42. package/dist/db/queries/concerns.d.ts +10 -0
  43. package/dist/db/queries/concerns.js +21 -0
  44. package/dist/db/queries/transactions.d.ts +3 -22
  45. package/dist/db/queries/transactions.js +4 -5
  46. package/dist/db/queries/unknowns.d.ts +62 -0
  47. package/dist/db/queries/unknowns.js +114 -0
  48. package/dist/db/schema.js +3 -3
  49. package/dist/resolver/pipeline.d.ts +16 -0
  50. package/dist/resolver/pipeline.js +38 -0
  51. package/dist/resolver/prompts.d.ts +8 -0
  52. package/dist/resolver/prompts.js +26 -0
  53. package/dist/scanner/account-mutex.d.ts +1 -0
  54. package/dist/scanner/account-mutex.js +16 -0
  55. package/dist/scanner/buffer.d.ts +10 -10
  56. package/dist/scanner/buffer.js +15 -15
  57. package/dist/scanner/decrypt-queue.d.ts +57 -0
  58. package/dist/scanner/decrypt-queue.js +114 -0
  59. package/dist/scanner/detectors/correlations.d.ts +2 -0
  60. package/dist/scanner/detectors/correlations.js +51 -0
  61. package/dist/scanner/detectors/duplicates.d.ts +2 -0
  62. package/dist/scanner/detectors/duplicates.js +75 -0
  63. package/dist/scanner/detectors/index.d.ts +18 -0
  64. package/dist/scanner/detectors/index.js +39 -0
  65. package/dist/scanner/detectors/recurrences.d.ts +2 -0
  66. package/dist/scanner/detectors/recurrences.js +49 -0
  67. package/dist/scanner/detectors/similar_accounts.d.ts +2 -0
  68. package/dist/scanner/detectors/similar_accounts.js +64 -0
  69. package/dist/scanner/detectors/similarities.d.ts +2 -0
  70. package/dist/scanner/detectors/similarities.js +73 -0
  71. package/dist/scanner/detectors/types.d.ts +16 -0
  72. package/dist/scanner/detectors/types.js +1 -0
  73. package/dist/scanner/inspectors/correlations.d.ts +2 -0
  74. package/dist/scanner/inspectors/correlations.js +47 -0
  75. package/dist/scanner/inspectors/duplicates.d.ts +2 -0
  76. package/dist/scanner/inspectors/duplicates.js +75 -0
  77. package/dist/scanner/inspectors/index.d.ts +19 -0
  78. package/dist/scanner/inspectors/index.js +39 -0
  79. package/dist/scanner/inspectors/recurrences.d.ts +2 -0
  80. package/dist/scanner/inspectors/recurrences.js +49 -0
  81. package/dist/scanner/inspectors/similarities.d.ts +2 -0
  82. package/dist/scanner/inspectors/similarities.js +73 -0
  83. package/dist/scanner/inspectors/types.d.ts +16 -0
  84. package/dist/scanner/inspectors/types.js +1 -0
  85. package/dist/scanner/pipeline.d.ts +6 -4
  86. package/dist/scanner/pipeline.js +51 -88
  87. package/dist/scanner/prompts.js +2 -2
  88. package/package.json +2 -1
@@ -0,0 +1,355 @@
1
+ export const TOP_LEVEL_TYPES = [
2
+ "asset", "liability", "income", "expense", "equity",
3
+ ];
4
+ const TYPE_ROOT_NAME = {
5
+ asset: "Assets",
6
+ liability: "Liabilities",
7
+ income: "Income",
8
+ expense: "Expenses",
9
+ equity: "Equity",
10
+ };
11
+ /**
12
+ * Balance per account using the natural debit/credit convention:
13
+ * asset / expense → debit-normal → balance = debits − credits
14
+ * liability / income / equity → credit-normal → balance = credits − debits
15
+ */
16
+ export function getAccountBalances(db, opts = {}) {
17
+ const params = [];
18
+ const where = [];
19
+ if (opts.type) {
20
+ where.push("a.type = ?");
21
+ params.push(opts.type);
22
+ }
23
+ const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
24
+ const rows = db.prepare(`SELECT a.*,
25
+ COALESCE(SUM(p.debit), 0) AS sum_debit,
26
+ COALESCE(SUM(p.credit), 0) AS sum_credit
27
+ FROM accounts a
28
+ LEFT JOIN postings p ON p.account_id = a.id
29
+ ${whereSql}
30
+ GROUP BY a.id
31
+ ORDER BY a.type, a.id`).all(...params);
32
+ return rows.map(r => {
33
+ const debitNormal = r.type === "asset" || r.type === "expense";
34
+ const balance = debitNormal ? r.sum_debit - r.sum_credit : r.sum_credit - r.sum_debit;
35
+ const { sum_debit: _d, sum_credit: _c, ...account } = r;
36
+ return { ...account, balance };
37
+ });
38
+ }
39
+ export function getNetWorth(db) {
40
+ const balances = getAccountBalances(db);
41
+ let assets = 0;
42
+ let liabilities = 0;
43
+ for (const b of balances) {
44
+ if (b.type === "asset")
45
+ assets += b.balance;
46
+ else if (b.type === "liability")
47
+ liabilities += b.balance;
48
+ }
49
+ return { assets, liabilities, net_worth: assets - liabilities };
50
+ }
51
+ export function getPeriodTotals(db, from, to) {
52
+ const row = db.prepare(`SELECT
53
+ COALESCE(SUM(CASE WHEN a.type = 'income' THEN p.credit - p.debit ELSE 0 END), 0) AS income,
54
+ COALESCE(SUM(CASE WHEN a.type = 'expense' THEN p.debit - p.credit ELSE 0 END), 0) AS expenses
55
+ FROM postings p
56
+ JOIN transactions t ON t.id = p.transaction_id
57
+ JOIN accounts a ON a.id = p.account_id
58
+ WHERE t.date BETWEEN ? AND ?`).get(from, to);
59
+ return { income: row.income, expenses: row.expenses };
60
+ }
61
+ export function findAccountById(db, id) {
62
+ return db.prepare(`SELECT * FROM accounts WHERE id = ?`).get(id) ?? null;
63
+ }
64
+ export function renameAccount(db, id, name) {
65
+ return db.prepare(`UPDATE accounts SET name = ? WHERE id = ?`).run(name, id).changes;
66
+ }
67
+ /**
68
+ * Idempotently insert one of the five top-level type roots (id = type name,
69
+ * parent_id = null). Called by `createAccount` when a child's declared parent
70
+ * is a missing top-level root.
71
+ */
72
+ export function ensureTopLevelRoot(db, type) {
73
+ if (findAccountById(db, type))
74
+ return;
75
+ db.prepare(`INSERT INTO accounts (id, name, type, parent_id) VALUES (?, ?, ?, NULL)`).run(type, TYPE_ROOT_NAME[type], type);
76
+ }
77
+ /**
78
+ * Idempotently insert one of the structural accounts the system auto-creates:
79
+ * - `expense:uncategorized` (suspense for unclassifiable expense postings)
80
+ * - `equity:adjustments` (balancing side of `adjust_account_balance`)
81
+ * - `equity:opening-balance` (starting state imports)
82
+ * The top-level root is bootstrapped first when missing.
83
+ */
84
+ export function ensureStructuralAccount(db, id) {
85
+ if (findAccountById(db, id))
86
+ return;
87
+ const [type, leaf] = id.split(":");
88
+ ensureTopLevelRoot(db, type);
89
+ const name = leaf === "uncategorized" ? "Uncategorized"
90
+ : leaf === "adjustments" ? "Adjustments"
91
+ : "Opening Balance";
92
+ db.prepare(`INSERT INTO accounts (id, name, type, parent_id) VALUES (?, ?, ?, ?)`).run(id, name, type, type);
93
+ }
94
+ /**
95
+ * Insert a new account row. Enforces the three hierarchy invariants:
96
+ * 1. Top-level roots: parent_id null, id == type, one of TOP_LEVEL_TYPES.
97
+ * 2. Children: parent_id non-null, parent must exist (the top-level root is
98
+ * auto-bootstrapped if missing — intermediate categories must be created
99
+ * explicitly), parent.type must equal input.type, input.id must start with
100
+ * parent.id + ':'.
101
+ * 3. UNIQUE on id (surfaces as code: 'ACCOUNT_EXISTS').
102
+ */
103
+ export function createAccount(db, input) {
104
+ const bank = input.bank_name ? String(input.bank_name).toUpperCase() : null;
105
+ const parentId = input.parent_id ?? null;
106
+ if (parentId === null) {
107
+ if (!TOP_LEVEL_TYPES.includes(input.id)) {
108
+ throw new Error(`Account "${input.id}" has no parent_id; only top-level type roots may have a null parent (one of ${TOP_LEVEL_TYPES.join(", ")}).`);
109
+ }
110
+ if (input.id !== input.type) {
111
+ throw new Error(`Top-level root id "${input.id}" must equal its type "${input.type}".`);
112
+ }
113
+ }
114
+ else {
115
+ let parent = findAccountById(db, parentId);
116
+ if (!parent) {
117
+ if (TOP_LEVEL_TYPES.includes(parentId)) {
118
+ ensureTopLevelRoot(db, parentId);
119
+ parent = findAccountById(db, parentId);
120
+ }
121
+ }
122
+ if (!parent) {
123
+ throw new Error(`Parent account "${parentId}" does not exist; create it first.`);
124
+ }
125
+ if (parent.type !== input.type) {
126
+ throw new Error(`Account "${input.id}" type "${input.type}" does not match parent "${parentId}" type "${parent.type}".`);
127
+ }
128
+ if (!input.id.startsWith(parent.id + ":")) {
129
+ throw new Error(`Account id "${input.id}" must start with parent id "${parent.id}:".`);
130
+ }
131
+ }
132
+ try {
133
+ db.prepare(`INSERT INTO accounts (id, name, type, parent_id, subtype, bank_name, account_number_masked, currency, due_day, statement_day, metadata_json)
134
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.id, input.name, input.type, parentId, input.subtype ?? null, bank, input.account_number_masked ?? null, input.currency ?? "THB", input.due_day ?? null, input.statement_day ?? null, input.metadata ? JSON.stringify(input.metadata) : null);
135
+ }
136
+ catch (err) {
137
+ if (String(err.message).includes("UNIQUE")) {
138
+ const dup = new Error(`Account "${input.id}" already exists.`);
139
+ dup.code = "ACCOUNT_EXISTS";
140
+ throw dup;
141
+ }
142
+ throw err;
143
+ }
144
+ }
145
+ /**
146
+ * Patch metadata fields on an account. Returns before/after snapshots of the
147
+ * touched fields so callers can persist a reversible audit record. `metadata`
148
+ * is shallow-merged into the existing metadata_json blob.
149
+ */
150
+ export function updateAccountMetadata(db, id, patch) {
151
+ const current = findAccountById(db, id);
152
+ if (!current)
153
+ throw new Error(`Account "${id}" not found.`);
154
+ const sets = [];
155
+ const params = [];
156
+ const before = {};
157
+ const after = {};
158
+ if (patch.due_day !== undefined) {
159
+ sets.push("due_day = ?");
160
+ params.push(patch.due_day);
161
+ before.due_day = current.due_day;
162
+ after.due_day = patch.due_day;
163
+ }
164
+ if (patch.statement_day !== undefined) {
165
+ sets.push("statement_day = ?");
166
+ params.push(patch.statement_day);
167
+ before.statement_day = current.statement_day;
168
+ after.statement_day = patch.statement_day;
169
+ }
170
+ if (patch.points_balance !== undefined) {
171
+ sets.push("points_balance = ?");
172
+ params.push(patch.points_balance);
173
+ before.points_balance = current.points_balance;
174
+ after.points_balance = patch.points_balance;
175
+ }
176
+ if (patch.account_number_masked !== undefined) {
177
+ sets.push("account_number_masked = ?");
178
+ params.push(patch.account_number_masked);
179
+ before.account_number_masked = current.account_number_masked;
180
+ after.account_number_masked = patch.account_number_masked;
181
+ }
182
+ if (patch.bank_name !== undefined) {
183
+ const next = patch.bank_name == null ? null : String(patch.bank_name).toUpperCase();
184
+ sets.push("bank_name = ?");
185
+ params.push(next);
186
+ before.bank_name = current.bank_name;
187
+ after.bank_name = next;
188
+ }
189
+ if (patch.metadata) {
190
+ const existing = current.metadata_json ? JSON.parse(current.metadata_json) : {};
191
+ const merged = { ...existing, ...patch.metadata };
192
+ sets.push("metadata_json = ?");
193
+ params.push(JSON.stringify(merged));
194
+ before.metadata = existing;
195
+ after.metadata = merged;
196
+ }
197
+ if (sets.length === 0)
198
+ return { before, after, changed: false };
199
+ params.push(id);
200
+ db.prepare(`UPDATE accounts SET ${sets.join(", ")} WHERE id = ?`).run(...params);
201
+ return { before, after, changed: true };
202
+ }
203
+ /**
204
+ * Re-point every posting on `fromId` to `toId`, then delete the source account.
205
+ * Wrapped in a transaction. Refuses if the source still has children. Returns
206
+ * the number of postings moved.
207
+ */
208
+ export function mergeAccounts(db, fromId, toId) {
209
+ if (fromId === toId)
210
+ throw new Error("Cannot merge an account into itself.");
211
+ const from = findAccountById(db, fromId);
212
+ if (!from)
213
+ throw new Error(`Source account ${fromId} not found.`);
214
+ const to = findAccountById(db, toId);
215
+ if (!to)
216
+ throw new Error(`Destination account ${toId} not found.`);
217
+ const childCount = db
218
+ .prepare(`SELECT COUNT(*) AS n FROM accounts WHERE parent_id = ?`)
219
+ .get(fromId);
220
+ if (childCount.n > 0) {
221
+ throw new Error(`Account ${fromId} has ${childCount.n} child account(s); merge or delete them first.`);
222
+ }
223
+ let moved = 0;
224
+ const tx = db.transaction(() => {
225
+ moved = db
226
+ .prepare(`UPDATE postings SET account_id = ? WHERE account_id = ?`)
227
+ .run(toId, fromId).changes;
228
+ db.prepare(`DELETE FROM accounts WHERE id = ?`).run(fromId);
229
+ });
230
+ tx();
231
+ return moved;
232
+ }
233
+ /** Delete an account only if no postings reference it AND it has no children. */
234
+ export function deleteAccount(db, id) {
235
+ const inUse = db
236
+ .prepare(`SELECT 1 FROM postings WHERE account_id = ? LIMIT 1`)
237
+ .get(id);
238
+ if (inUse) {
239
+ throw new Error(`Account ${id} still has postings; merge it first.`);
240
+ }
241
+ const childCount = db
242
+ .prepare(`SELECT COUNT(*) AS n FROM accounts WHERE parent_id = ?`)
243
+ .get(id);
244
+ if (childCount.n > 0) {
245
+ throw new Error(`Account ${id} has ${childCount.n} child account(s); delete them first.`);
246
+ }
247
+ db.prepare(`DELETE FROM accounts WHERE id = ?`).run(id);
248
+ }
249
+ /**
250
+ * Recursive CTE walk over `accounts.parent_id` returning the root and every
251
+ * descendant. Used by `getRollupBalance` and by hierarchical rendering paths.
252
+ */
253
+ export function getAccountSubtree(db, rootId) {
254
+ return db.prepare(`WITH RECURSIVE subtree AS (
255
+ SELECT * FROM accounts WHERE id = ?
256
+ UNION ALL
257
+ SELECT a.* FROM accounts a JOIN subtree s ON a.parent_id = s.id
258
+ )
259
+ SELECT * FROM subtree ORDER BY id`).all(rootId);
260
+ }
261
+ /**
262
+ * Sum the natural balance of every account in a subtree (root inclusive).
263
+ * Uses the same debit-normal / credit-normal convention as `getAccountBalances`.
264
+ */
265
+ export function getRollupBalance(db, rootId) {
266
+ const subtree = getAccountSubtree(db, rootId);
267
+ if (subtree.length === 0)
268
+ return 0;
269
+ const ids = subtree.map(a => a.id);
270
+ const placeholders = ids.map(() => "?").join(",");
271
+ const row = db.prepare(`SELECT a.type,
272
+ COALESCE(SUM(p.debit), 0) AS sum_debit,
273
+ COALESCE(SUM(p.credit), 0) AS sum_credit
274
+ FROM accounts a
275
+ LEFT JOIN postings p ON p.account_id = a.id
276
+ WHERE a.id IN (${placeholders})
277
+ GROUP BY a.type`).all(...ids);
278
+ let total = 0;
279
+ for (const r of row) {
280
+ const debitNormal = r.type === "asset" || r.type === "expense";
281
+ total += debitNormal ? r.sum_debit - r.sum_credit : r.sum_credit - r.sum_debit;
282
+ }
283
+ return total;
284
+ }
285
+ /**
286
+ * Pairwise Levenshtein similarity over `accounts.name`. Returns pairs above the
287
+ * threshold (0–1, where 1 = identical), sorted highest first. Quadratic in the
288
+ * number of accounts — fine for the small N a personal chart of accounts holds.
289
+ */
290
+ export function findSimilarAccounts(db, threshold = 0.85) {
291
+ const rows = db.prepare(`SELECT * FROM accounts ORDER BY name`).all();
292
+ const pairs = [];
293
+ for (let i = 0; i < rows.length; i++) {
294
+ for (let j = i + 1; j < rows.length; j++) {
295
+ const sim = similarity(rows[i].name.toLowerCase(), rows[j].name.toLowerCase());
296
+ if (sim >= threshold)
297
+ pairs.push({ a: rows[i], b: rows[j], similarity: Math.round(sim * 1000) / 1000 });
298
+ }
299
+ }
300
+ pairs.sort((x, y) => y.similarity - x.similarity);
301
+ return pairs;
302
+ }
303
+ /**
304
+ * Rank the chart of accounts by name similarity to a free-text query. Returns
305
+ * matches at or above `threshold`, highest first. Bonus weight when the query
306
+ * is a substring of the name so "ttb saving" still finds "TTB Savings ••1234"
307
+ * even though pure Levenshtein on the full strings is mediocre.
308
+ */
309
+ export function findAccountsByFuzzyName(db, query, threshold = 0.5) {
310
+ const q = query.trim().toLowerCase();
311
+ if (!q)
312
+ return [];
313
+ const rows = db.prepare(`SELECT * FROM accounts ORDER BY name`).all();
314
+ const out = [];
315
+ for (const row of rows) {
316
+ const name = row.name.toLowerCase();
317
+ let score = similarity(q, name);
318
+ if (name.includes(q) || q.includes(name))
319
+ score = Math.max(score, 0.85);
320
+ if (score >= threshold) {
321
+ out.push({ account: row, similarity: Math.round(score * 1000) / 1000 });
322
+ }
323
+ }
324
+ out.sort((a, b) => b.similarity - a.similarity);
325
+ return out;
326
+ }
327
+ function similarity(a, b) {
328
+ if (a === b)
329
+ return 1;
330
+ if (a.length === 0 || b.length === 0)
331
+ return 0;
332
+ const dist = levenshtein(a, b);
333
+ return 1 - dist / Math.max(a.length, b.length);
334
+ }
335
+ function levenshtein(a, b) {
336
+ const m = a.length, n = b.length;
337
+ if (m === 0)
338
+ return n;
339
+ if (n === 0)
340
+ return m;
341
+ const prev = new Array(n + 1);
342
+ const curr = new Array(n + 1);
343
+ for (let j = 0; j <= n; j++)
344
+ prev[j] = j;
345
+ for (let i = 1; i <= m; i++) {
346
+ curr[0] = i;
347
+ for (let j = 1; j <= n; j++) {
348
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
349
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
350
+ }
351
+ for (let j = 0; j <= n; j++)
352
+ prev[j] = curr[j];
353
+ }
354
+ return prev[n];
355
+ }
@@ -127,7 +127,6 @@ export interface SimilarAccountPair {
127
127
  * number of accounts — fine for the small N a personal chart of accounts holds.
128
128
  */
129
129
  export declare function findSimilarAccounts(db: Database.Database, threshold?: number): SimilarAccountPair[];
130
- export declare function findUnusedAccounts(db: Database.Database): AccountRow[];
131
130
  export interface FuzzyAccountMatch {
132
131
  account: AccountRow;
133
132
  similarity: number;
@@ -300,16 +300,6 @@ export function findSimilarAccounts(db, threshold = 0.85) {
300
300
  pairs.sort((x, y) => y.similarity - x.similarity);
301
301
  return pairs;
302
302
  }
303
- export function findUnusedAccounts(db) {
304
- return db
305
- .prepare(`SELECT a.* FROM accounts a
306
- LEFT JOIN postings p ON p.account_id = a.id
307
- WHERE p.id IS NULL
308
- AND NOT EXISTS (SELECT 1 FROM accounts c WHERE c.parent_id = a.id)
309
- AND a.id NOT IN ('asset','liability','income','expense','equity')
310
- ORDER BY a.name`)
311
- .all();
312
- }
313
303
  /**
314
304
  * Rank the chart of accounts by name similarity to a free-text query. Returns
315
305
  * matches at or above `threshold`, highest first. Bonus weight when the query
@@ -0,0 +1,29 @@
1
+ import type Database from "libsql";
2
+ export type ActionCommand = "record" | "scan" | "resolve";
3
+ export type ActionType = "create_account" | "update_account_metadata" | "record_transaction" | "adjust_balance" | "create_merchant" | "update_merchant_default";
4
+ export interface ActionLogInput {
5
+ correlation_id: string;
6
+ command: ActionCommand;
7
+ user_input?: string | null;
8
+ action_type: ActionType;
9
+ target_id: string;
10
+ payload: Record<string, unknown>;
11
+ }
12
+ export interface ActionLogRow {
13
+ id: string;
14
+ correlation_id: string;
15
+ command: ActionCommand;
16
+ user_input: string | null;
17
+ action_type: ActionType;
18
+ target_id: string;
19
+ payload_json: string;
20
+ created_at: string;
21
+ reverted_at: string | null;
22
+ }
23
+ export declare function appendAction(db: Database.Database, input: ActionLogInput): string;
24
+ export interface ListActionsOptions {
25
+ limit?: number;
26
+ command?: ActionCommand;
27
+ correlationId?: string;
28
+ }
29
+ export declare function listActions(db: Database.Database, opts?: ListActionsOptions): ActionLogRow[];
@@ -0,0 +1,27 @@
1
+ import { randomUUID } from "crypto";
2
+ export function appendAction(db, input) {
3
+ const id = `al:${randomUUID()}`;
4
+ db.prepare(`INSERT INTO action_log
5
+ (id, correlation_id, command, user_input, action_type, target_id, payload_json)
6
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(id, input.correlation_id, input.command, input.user_input ?? null, input.action_type, input.target_id, JSON.stringify(input.payload));
7
+ return id;
8
+ }
9
+ export function listActions(db, opts = {}) {
10
+ const conds = [];
11
+ const params = [];
12
+ if (opts.command) {
13
+ conds.push("command = ?");
14
+ params.push(opts.command);
15
+ }
16
+ if (opts.correlationId) {
17
+ conds.push("correlation_id = ?");
18
+ params.push(opts.correlationId);
19
+ }
20
+ const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
21
+ const limit = Math.min(Math.max(opts.limit ?? 100, 1), 1000);
22
+ return db
23
+ .prepare(`SELECT id, correlation_id, command, user_input, action_type, target_id,
24
+ payload_json, created_at, reverted_at
25
+ FROM action_log ${where} ORDER BY rowid ASC LIMIT ?`)
26
+ .all(...params, limit);
27
+ }
@@ -1,5 +1,5 @@
1
1
  import type Database from "libsql";
2
- export type ActionCommand = "record" | "scan" | "review";
2
+ export type ActionCommand = "record" | "scan" | "resolve";
3
3
  export type ActionType = "create_account" | "update_account_metadata" | "record_transaction" | "adjust_balance" | "create_merchant" | "update_merchant_default";
4
4
  export interface ActionLogInput {
5
5
  correlation_id: string;
@@ -48,3 +48,13 @@ export interface CountOpenConcernsScope {
48
48
  }
49
49
  export declare function countOpenConcerns(db: Database.Database, scope?: CountOpenConcernsScope): number;
50
50
  export declare function listOpenConcerns(db: Database.Database, limit?: number): OpenConcernRow[];
51
+ /**
52
+ * Open concerns filtered by `kind`, ordered by the position of the kind in the
53
+ * input array (priority) then by created_at. Pass `["uncategorized","duplicate"]`
54
+ * to drain uncategorized concerns before duplicates.
55
+ *
56
+ * `kind` is free-text TEXT in the schema; canonical values used by built-ins:
57
+ * uncategorized, duplicate, correlation, recurrence_candidate,
58
+ * similar_accounts, file_password
59
+ */
60
+ export declare function listOpenConcernsByKind(db: Database.Database, kinds: string[], limit?: number): OpenConcernRow[];
@@ -89,3 +89,24 @@ export function listOpenConcerns(db, limit = 50) {
89
89
  ORDER BY created_at ASC
90
90
  LIMIT ?`).all(capped);
91
91
  }
92
+ /**
93
+ * Open concerns filtered by `kind`, ordered by the position of the kind in the
94
+ * input array (priority) then by created_at. Pass `["uncategorized","duplicate"]`
95
+ * to drain uncategorized concerns before duplicates.
96
+ *
97
+ * `kind` is free-text TEXT in the schema; canonical values used by built-ins:
98
+ * uncategorized, duplicate, correlation, recurrence_candidate,
99
+ * similar_accounts, file_password
100
+ */
101
+ export function listOpenConcernsByKind(db, kinds, limit = 50) {
102
+ if (kinds.length === 0)
103
+ return [];
104
+ const capped = Math.min(Math.max(limit, 1), 200);
105
+ const placeholders = kinds.map(() => "?").join(",");
106
+ const cases = kinds.map((_, i) => `WHEN ? THEN ${i}`).join(" ");
107
+ return db.prepare(`SELECT id, file_id, transaction_id, account_id, kind, prompt, options_json, created_at
108
+ FROM concerns
109
+ WHERE resolved_at IS NULL AND kind IN (${placeholders})
110
+ ORDER BY CASE kind ${cases} ELSE ${kinds.length} END, created_at ASC
111
+ LIMIT ?`).all(...kinds, ...kinds, capped);
112
+ }
@@ -9,7 +9,7 @@ export interface PostingInput {
9
9
  pii_flag?: boolean;
10
10
  }
11
11
  export interface TransactionInput {
12
- /** Optional pre-assigned id. Used by the buffered-write path so concerns recorded mid-scan can reference the transaction before commit. */
12
+ /** Optional pre-assigned id. Used by the buffered-write path so unknowns recorded mid-scan can reference the transaction before commit. */
13
13
  id?: string;
14
14
  date: string;
15
15
  description: string;
@@ -90,6 +90,8 @@ export interface DuplicateGroupTransaction {
90
90
  date: string;
91
91
  description: string;
92
92
  amount: number;
93
+ source_file_id: string | null;
94
+ merchant_id: string | null;
93
95
  account_ids: string[];
94
96
  account_names: string[];
95
97
  }
@@ -143,25 +145,4 @@ export interface FindCorrelatedTransactionsOptions {
143
145
  * pairs whose account-id sets overlap (those are duplicates, not correlations).
144
146
  */
145
147
  export declare function findCorrelatedTransactions(db: Database.Database, opts?: FindCorrelatedTransactionsOptions): CorrelatedTransactionPair[];
146
- export interface CorrelationCandidate {
147
- id: string;
148
- date: string;
149
- description: string;
150
- amount: number;
151
- currency: string;
152
- account_ids: string[];
153
- account_names: string[];
154
- }
155
- /**
156
- * Pure pair-finder: given an array of candidates already filtered by amount
157
- * and equipped with account_ids/names, return the cross-pairs that look like
158
- * the same money movement on different accounts (date within toleranceDays,
159
- * same amount + currency, non-overlapping account sets).
160
- *
161
- * Used by the DB-backed `findCorrelatedTransactions` and by the scan-time
162
- * coordinator that runs over buffered, not-yet-committed transactions.
163
- */
164
- export declare function correlatePairs(candidates: CorrelationCandidate[], opts?: {
165
- toleranceDays?: number;
166
- }): CorrelatedTransactionPair[];
167
148
  export declare function listPostings(db: Database.Database, opts?: ListPostingsOptions): PostingRow[];
@@ -123,7 +123,7 @@ export function findDuplicateTransactions(db, opts = {}) {
123
123
  : ``;
124
124
  const params = opts.accountId ? [opts.accountId] : [];
125
125
  const nameById = loadAccountNames(db);
126
- const rows = db.prepare(`SELECT t.id, t.date, t.description,
126
+ const rows = db.prepare(`SELECT t.id, t.date, t.description, t.source_file_id, t.merchant_id,
127
127
  COALESCE(SUM(p.debit), 0) AS amount,
128
128
  GROUP_CONCAT(p.account_id) AS account_ids
129
129
  FROM transactions t
@@ -139,6 +139,8 @@ export function findDuplicateTransactions(db, opts = {}) {
139
139
  date: r.date,
140
140
  description: r.description,
141
141
  amount: Math.round(r.amount * 100) / 100,
142
+ source_file_id: r.source_file_id,
143
+ merchant_id: r.merchant_id,
142
144
  account_ids: ids,
143
145
  account_names: ids.map(id => nameById.get(id) ?? id),
144
146
  };
@@ -246,11 +248,8 @@ export function findCorrelatedTransactions(db, opts = {}) {
246
248
  * and equipped with account_ids/names, return the cross-pairs that look like
247
249
  * the same money movement on different accounts (date within toleranceDays,
248
250
  * 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
251
  */
253
- export function correlatePairs(candidates, opts = {}) {
252
+ function correlatePairs(candidates, opts = {}) {
254
253
  const toleranceDays = Math.max(0, Math.floor(opts.toleranceDays ?? 3));
255
254
  const buckets = new Map();
256
255
  for (const e of candidates) {
@@ -0,0 +1,62 @@
1
+ import type Database from "libsql";
2
+ export interface UnknownTarget {
3
+ transaction_id: string | null;
4
+ account_id: string | null;
5
+ }
6
+ export interface RecordUnknownInput extends UnknownTarget {
7
+ file_id: string | null;
8
+ kind?: string | null;
9
+ prompt: string;
10
+ options?: string[];
11
+ }
12
+ export interface OpenUnknownRow {
13
+ id: string;
14
+ file_id: string | null;
15
+ transaction_id: string | null;
16
+ account_id: string | null;
17
+ kind: string | null;
18
+ prompt: string;
19
+ options_json: string | null;
20
+ created_at: string;
21
+ }
22
+ /**
23
+ * Insert a new unknowns row and flip the `has_unknown` boolean on whichever
24
+ * target (transaction / account) was named. Returns the new id. The id keeps
25
+ * the historical `cn:` prefix — it's opaque and nothing else references it,
26
+ * so the prefix is a no-op detail.
27
+ */
28
+ export declare function recordUnknown(db: Database.Database, input: RecordUnknownInput): string;
29
+ /**
30
+ * Mark an existing unknown as resolved with the user's answer and, if no other
31
+ * open unknowns reference the same target, clear the target's `has_unknown`
32
+ * flag. Returns the unknown's target so callers can log or react.
33
+ */
34
+ export declare function resolveUnknown(db: Database.Database, id: string, answer: string): UnknownTarget | null;
35
+ /**
36
+ * Look up the transaction/account an unknown is attached to. Returns null when
37
+ * the unknown id doesn't exist.
38
+ */
39
+ export declare function getUnknownTarget(db: Database.Database, id: string): UnknownTarget | null;
40
+ /**
41
+ * Clear `has_unknown` on the named transaction / account if no other open
42
+ * unknowns still reference it. Safe to call after any resolution; idempotent.
43
+ */
44
+ export declare function maybeClearHasUnknownFlags(db: Database.Database, target: UnknownTarget): void;
45
+ export interface CountOpenUnknownsScope {
46
+ file_id?: string;
47
+ transaction_id?: string;
48
+ account_id?: string;
49
+ kind?: string;
50
+ }
51
+ export declare function countOpenUnknowns(db: Database.Database, scope?: CountOpenUnknownsScope): number;
52
+ export declare function listOpenUnknowns(db: Database.Database, limit?: number): OpenUnknownRow[];
53
+ /**
54
+ * Open unknowns filtered by `kind`, ordered by the position of the kind in the
55
+ * input array (priority) then by created_at. Pass `["uncategorized","duplicate"]`
56
+ * to drain uncategorized rows before duplicates.
57
+ *
58
+ * `kind` is free-text TEXT in the schema; canonical values used by built-ins:
59
+ * uncategorized, duplicate, correlation, recurrence_candidate,
60
+ * similar_accounts, file_password
61
+ */
62
+ export declare function listOpenUnknownsByKind(db: Database.Database, kinds: string[], limit?: number): OpenUnknownRow[];