plasalid 0.6.9 → 0.7.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 +3 -5
- package/dist/accounts/taxonomy.d.ts +0 -23
- package/dist/accounts/taxonomy.js +15 -15
- package/dist/ai/agent.d.ts +4 -4
- package/dist/ai/agent.js +9 -8
- package/dist/ai/context.d.ts +0 -2
- package/dist/ai/context.js +2 -2
- package/dist/ai/memory.d.ts +1 -0
- package/dist/ai/memory.js +4 -0
- package/dist/ai/personas.js +3 -6
- package/dist/ai/provider.d.ts +1 -0
- package/dist/ai/thinking.d.ts +0 -6
- package/dist/ai/thinking.js +29 -4
- package/dist/ai/tools/index.d.ts +5 -1
- package/dist/ai/tools/index.js +21 -15
- package/dist/ai/tools/ingest.js +94 -110
- package/dist/ai/tools/resolve.js +15 -44
- package/dist/cli/commands/accounts.d.ts +4 -1
- package/dist/cli/commands/accounts.js +39 -20
- package/dist/cli/commands/scan.js +47 -47
- package/dist/cli/commands/status.js +81 -14
- package/dist/cli/commands/transactions.d.ts +3 -1
- package/dist/cli/commands/transactions.js +37 -34
- package/dist/cli/format.d.ts +0 -1
- package/dist/cli/format.js +1 -1
- package/dist/cli/helper.d.ts +11 -0
- package/dist/cli/helper.js +24 -0
- package/dist/cli/index.js +14 -10
- package/dist/cli/ink/AccountsBrowser.d.ts +7 -0
- package/dist/cli/ink/AccountsBrowser.js +149 -0
- package/dist/cli/ink/ListBrowser.d.ts +38 -0
- package/dist/cli/ink/ListBrowser.js +154 -0
- package/dist/cli/ink/TransactionsBrowser.d.ts +6 -0
- package/dist/cli/ink/TransactionsBrowser.js +87 -0
- package/dist/cli/ink/hooks/useFooterText.js +30 -11
- package/dist/cli/ink/runBrowser.d.ts +7 -0
- package/dist/cli/ink/runBrowser.js +24 -0
- package/dist/cli/ux.d.ts +4 -5
- package/dist/cli/ux.js +87 -66
- package/dist/db/connection.d.ts +0 -2
- package/dist/db/connection.js +0 -5
- package/dist/db/queries/files.d.ts +11 -0
- package/dist/db/queries/files.js +16 -0
- package/dist/db/queries/recurrences.d.ts +7 -0
- package/dist/db/queries/recurrences.js +21 -0
- package/dist/db/queries/transactions.d.ts +28 -4
- package/dist/db/queries/transactions.js +68 -15
- package/dist/db/queries/unknowns.d.ts +3 -5
- package/dist/db/queries/unknowns.js +4 -4
- package/dist/db/schema.js +8 -0
- package/dist/lib/runPasses.d.ts +30 -0
- package/dist/lib/runPasses.js +15 -0
- package/dist/resolver/pipeline.d.ts +6 -6
- package/dist/resolver/pipeline.js +50 -22
- package/dist/scanner/inspectors/similarities.js +14 -16
- package/package.json +2 -2
|
@@ -34,6 +34,7 @@ export interface PostingRow {
|
|
|
34
34
|
transaction_date?: string;
|
|
35
35
|
transaction_description?: string;
|
|
36
36
|
merchant_name?: string | null;
|
|
37
|
+
transaction_recurrence_id?: string | null;
|
|
37
38
|
}
|
|
38
39
|
/**
|
|
39
40
|
* Insert a balanced transaction. Throws if SUM(debit) !== SUM(credit) or any
|
|
@@ -42,9 +43,10 @@ export interface PostingRow {
|
|
|
42
43
|
*/
|
|
43
44
|
export declare function recordTransaction(db: Database.Database, input: TransactionInput): string;
|
|
44
45
|
/**
|
|
45
|
-
* Validate
|
|
46
|
-
*
|
|
47
|
-
*
|
|
46
|
+
* Validate structural invariants and assign an id. Pure (no DB writes).
|
|
47
|
+
* Balance equality is **not** checked here — `insertTransactionRows` closes any
|
|
48
|
+
* imbalance with an `equity:adjustments` posting at insert time, so callers
|
|
49
|
+
* can record whatever the source document (or the LLM) actually saw.
|
|
48
50
|
*/
|
|
49
51
|
export declare function validateTransaction(input: TransactionInput): TransactionInput & {
|
|
50
52
|
id: string;
|
|
@@ -52,7 +54,9 @@ export declare function validateTransaction(input: TransactionInput): Transactio
|
|
|
52
54
|
/**
|
|
53
55
|
* Insert-only counterpart to `recordTransaction`. The caller is responsible
|
|
54
56
|
* for opening a transaction (or for accepting partial writes). Expects an
|
|
55
|
-
* already-validated input from `validateTransaction`.
|
|
57
|
+
* already-validated input from `validateTransaction`. If the postings don't
|
|
58
|
+
* sum to zero, a closing entry on `equity:adjustments` is appended so the
|
|
59
|
+
* double-entry invariant always holds at the row level.
|
|
56
60
|
*/
|
|
57
61
|
export declare function insertTransactionRows(db: Database.Database, input: TransactionInput & {
|
|
58
62
|
id: string;
|
|
@@ -146,3 +150,23 @@ export interface FindCorrelatedTransactionsOptions {
|
|
|
146
150
|
*/
|
|
147
151
|
export declare function findCorrelatedTransactions(db: Database.Database, opts?: FindCorrelatedTransactionsOptions): CorrelatedTransactionPair[];
|
|
148
152
|
export declare function listPostings(db: Database.Database, opts?: ListPostingsOptions): PostingRow[];
|
|
153
|
+
export interface TransactionGroup {
|
|
154
|
+
transaction_id: string;
|
|
155
|
+
date: string;
|
|
156
|
+
description: string;
|
|
157
|
+
merchant: string | null;
|
|
158
|
+
recurrence_id: string | null;
|
|
159
|
+
postings: PostingRow[];
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Fold `listPostings` output into per-transaction groups, surfacing the header
|
|
163
|
+
* fields (date, description, merchant, recurrence) shared by every posting
|
|
164
|
+
* under that transaction. Assumes rows are already in transaction-id order —
|
|
165
|
+
* `listPostings` produces that ordering naturally via its `ORDER BY t.date DESC, t.id DESC`.
|
|
166
|
+
*/
|
|
167
|
+
export declare function groupByTransaction(postings: PostingRow[]): TransactionGroup[];
|
|
168
|
+
export interface TransactionTotals {
|
|
169
|
+
transactions: number;
|
|
170
|
+
postings: number;
|
|
171
|
+
}
|
|
172
|
+
export declare function countTransactions(db: Database.Database): TransactionTotals;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
2
|
import { upsertMerchant } from "./merchants.js";
|
|
3
|
-
|
|
3
|
+
import { ensureStructuralAccount } from "./account-balance.js";
|
|
4
4
|
/**
|
|
5
5
|
* Insert a balanced transaction. Throws if SUM(debit) !== SUM(credit) or any
|
|
6
6
|
* posting both debits and credits. Transaction-wrapped: postings never land
|
|
@@ -13,16 +13,15 @@ export function recordTransaction(db, input) {
|
|
|
13
13
|
return validated.id;
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
|
-
* Validate
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Validate structural invariants and assign an id. Pure (no DB writes).
|
|
17
|
+
* Balance equality is **not** checked here — `insertTransactionRows` closes any
|
|
18
|
+
* imbalance with an `equity:adjustments` posting at insert time, so callers
|
|
19
|
+
* can record whatever the source document (or the LLM) actually saw.
|
|
19
20
|
*/
|
|
20
21
|
export function validateTransaction(input) {
|
|
21
|
-
if (!input.postings || input.postings.length <
|
|
22
|
-
throw new Error("Transaction must contain at least
|
|
22
|
+
if (!input.postings || input.postings.length < 1) {
|
|
23
|
+
throw new Error("Transaction must contain at least one posting.");
|
|
23
24
|
}
|
|
24
|
-
let debitTotal = 0;
|
|
25
|
-
let creditTotal = 0;
|
|
26
25
|
for (const p of input.postings) {
|
|
27
26
|
const debit = p.debit ?? 0;
|
|
28
27
|
const credit = p.credit ?? 0;
|
|
@@ -35,20 +34,18 @@ export function validateTransaction(input) {
|
|
|
35
34
|
if (debit === 0 && credit === 0) {
|
|
36
35
|
throw new Error("Each posting must have either a debit or a credit.");
|
|
37
36
|
}
|
|
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
37
|
}
|
|
44
38
|
return { ...input, id: input.id ?? `tx:${randomUUID()}` };
|
|
45
39
|
}
|
|
46
40
|
/**
|
|
47
41
|
* Insert-only counterpart to `recordTransaction`. The caller is responsible
|
|
48
42
|
* for opening a transaction (or for accepting partial writes). Expects an
|
|
49
|
-
* already-validated input from `validateTransaction`.
|
|
43
|
+
* already-validated input from `validateTransaction`. If the postings don't
|
|
44
|
+
* sum to zero, a closing entry on `equity:adjustments` is appended so the
|
|
45
|
+
* double-entry invariant always holds at the row level.
|
|
50
46
|
*/
|
|
51
47
|
export function insertTransactionRows(db, input) {
|
|
48
|
+
const postings = balanceWithAdjustment(db, input.postings);
|
|
52
49
|
let merchantId = input.merchant_id ?? null;
|
|
53
50
|
if (!merchantId && input.merchant) {
|
|
54
51
|
merchantId = upsertMerchant(db, input.merchant).id;
|
|
@@ -57,10 +54,33 @@ export function insertTransactionRows(db, input) {
|
|
|
57
54
|
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(input.id, input.date, input.description, merchantId, input.raw_descriptor ?? null, input.source_file_id ?? null, input.source_page ?? null);
|
|
58
55
|
const insertPosting = db.prepare(`INSERT INTO postings (id, transaction_id, account_id, debit, credit, currency, memo, pii_flag)
|
|
59
56
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
60
|
-
for (const p of
|
|
57
|
+
for (const p of postings) {
|
|
61
58
|
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
59
|
}
|
|
63
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* If the postings don't tie, append a closing entry on `equity:adjustments`.
|
|
63
|
+
* Sums in integer cents to avoid float drift — no tolerance constant needed.
|
|
64
|
+
* Returns the original list when already balanced.
|
|
65
|
+
*/
|
|
66
|
+
function balanceWithAdjustment(db, postings) {
|
|
67
|
+
let debitCents = 0;
|
|
68
|
+
let creditCents = 0;
|
|
69
|
+
for (const p of postings) {
|
|
70
|
+
debitCents += Math.round((p.debit ?? 0) * 100);
|
|
71
|
+
creditCents += Math.round((p.credit ?? 0) * 100);
|
|
72
|
+
}
|
|
73
|
+
const diffCents = debitCents - creditCents;
|
|
74
|
+
if (diffCents === 0)
|
|
75
|
+
return postings;
|
|
76
|
+
ensureStructuralAccount(db, "equity:adjustments");
|
|
77
|
+
const amount = Math.abs(diffCents) / 100;
|
|
78
|
+
const currency = postings[0]?.currency || "THB";
|
|
79
|
+
const adjustment = diffCents > 0
|
|
80
|
+
? { account_id: "equity:adjustments", credit: amount, currency }
|
|
81
|
+
: { account_id: "equity:adjustments", debit: amount, currency };
|
|
82
|
+
return [...postings, adjustment];
|
|
83
|
+
}
|
|
64
84
|
export function updateTransaction(db, transactionId, fields) {
|
|
65
85
|
const sets = [];
|
|
66
86
|
const params = [];
|
|
@@ -308,6 +328,7 @@ export function listPostings(db, opts = {}) {
|
|
|
308
328
|
return db.prepare(`SELECT p.id, p.transaction_id, p.account_id, p.debit, p.credit, p.currency, p.memo,
|
|
309
329
|
a.name AS account_name, a.type AS account_type,
|
|
310
330
|
t.date AS transaction_date, t.description AS transaction_description,
|
|
331
|
+
t.recurrence_id AS transaction_recurrence_id,
|
|
311
332
|
m.canonical_name AS merchant_name
|
|
312
333
|
FROM postings p
|
|
313
334
|
JOIN transactions t ON t.id = p.transaction_id
|
|
@@ -317,3 +338,35 @@ export function listPostings(db, opts = {}) {
|
|
|
317
338
|
ORDER BY t.date DESC, t.id DESC
|
|
318
339
|
LIMIT ?`).all(...params, limit);
|
|
319
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* Fold `listPostings` output into per-transaction groups, surfacing the header
|
|
343
|
+
* fields (date, description, merchant, recurrence) shared by every posting
|
|
344
|
+
* under that transaction. Assumes rows are already in transaction-id order —
|
|
345
|
+
* `listPostings` produces that ordering naturally via its `ORDER BY t.date DESC, t.id DESC`.
|
|
346
|
+
*/
|
|
347
|
+
export function groupByTransaction(postings) {
|
|
348
|
+
const groups = [];
|
|
349
|
+
let current = null;
|
|
350
|
+
for (const p of postings) {
|
|
351
|
+
if (!current || current.transaction_id !== p.transaction_id) {
|
|
352
|
+
current = {
|
|
353
|
+
transaction_id: p.transaction_id,
|
|
354
|
+
date: p.transaction_date ?? "",
|
|
355
|
+
description: p.transaction_description ?? "",
|
|
356
|
+
merchant: p.merchant_name ?? null,
|
|
357
|
+
recurrence_id: p.transaction_recurrence_id ?? null,
|
|
358
|
+
postings: [],
|
|
359
|
+
};
|
|
360
|
+
groups.push(current);
|
|
361
|
+
}
|
|
362
|
+
current.postings.push(p);
|
|
363
|
+
}
|
|
364
|
+
return groups;
|
|
365
|
+
}
|
|
366
|
+
export function countTransactions(db) {
|
|
367
|
+
const row = db
|
|
368
|
+
.prepare(`SELECT (SELECT COUNT(*) FROM transactions) AS transactions,
|
|
369
|
+
(SELECT COUNT(*) FROM postings) AS postings`)
|
|
370
|
+
.get();
|
|
371
|
+
return row;
|
|
372
|
+
}
|
|
@@ -8,6 +8,8 @@ export interface RecordUnknownInput extends UnknownTarget {
|
|
|
8
8
|
kind?: string | null;
|
|
9
9
|
prompt: string;
|
|
10
10
|
options?: string[];
|
|
11
|
+
/** Kind-specific structured context (e.g. partner ids for similar_accounts). */
|
|
12
|
+
context?: Record<string, unknown> | null;
|
|
11
13
|
}
|
|
12
14
|
export interface OpenUnknownRow {
|
|
13
15
|
id: string;
|
|
@@ -17,6 +19,7 @@ export interface OpenUnknownRow {
|
|
|
17
19
|
kind: string | null;
|
|
18
20
|
prompt: string;
|
|
19
21
|
options_json: string | null;
|
|
22
|
+
context_json: string | null;
|
|
20
23
|
created_at: string;
|
|
21
24
|
}
|
|
22
25
|
/**
|
|
@@ -37,11 +40,6 @@ export declare function resolveUnknown(db: Database.Database, id: string, answer
|
|
|
37
40
|
* the unknown id doesn't exist.
|
|
38
41
|
*/
|
|
39
42
|
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
43
|
export interface CountOpenUnknownsScope {
|
|
46
44
|
file_id?: string;
|
|
47
45
|
transaction_id?: string;
|
|
@@ -7,7 +7,7 @@ import { randomUUID } from "crypto";
|
|
|
7
7
|
*/
|
|
8
8
|
export function recordUnknown(db, input) {
|
|
9
9
|
const id = `cn:${randomUUID()}`;
|
|
10
|
-
db.prepare(`INSERT INTO unknowns (id, file_id, transaction_id, account_id, kind, prompt, options_json) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(id, input.file_id, input.transaction_id, input.account_id, input.kind ?? null, input.prompt, input.options ? JSON.stringify(input.options) : null);
|
|
10
|
+
db.prepare(`INSERT INTO unknowns (id, file_id, transaction_id, account_id, kind, prompt, options_json, context_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(id, input.file_id, input.transaction_id, input.account_id, input.kind ?? null, input.prompt, input.options ? JSON.stringify(input.options) : null, input.context ? JSON.stringify(input.context) : null);
|
|
11
11
|
if (input.transaction_id) {
|
|
12
12
|
db.prepare(`UPDATE transactions SET has_unknown = 1 WHERE id = ?`).run(input.transaction_id);
|
|
13
13
|
}
|
|
@@ -43,7 +43,7 @@ export function getUnknownTarget(db, id) {
|
|
|
43
43
|
* Clear `has_unknown` on the named transaction / account if no other open
|
|
44
44
|
* unknowns still reference it. Safe to call after any resolution; idempotent.
|
|
45
45
|
*/
|
|
46
|
-
|
|
46
|
+
function maybeClearHasUnknownFlags(db, target) {
|
|
47
47
|
if (target.transaction_id) {
|
|
48
48
|
const open = db
|
|
49
49
|
.prepare(`SELECT 1 FROM unknowns WHERE transaction_id = ? AND resolved_at IS NULL LIMIT 1`)
|
|
@@ -85,7 +85,7 @@ export function countOpenUnknowns(db, scope = {}) {
|
|
|
85
85
|
}
|
|
86
86
|
export function listOpenUnknowns(db, limit = 50) {
|
|
87
87
|
const capped = Math.min(Math.max(limit, 1), 200);
|
|
88
|
-
return db.prepare(`SELECT id, file_id, transaction_id, account_id, kind, prompt, options_json, created_at
|
|
88
|
+
return db.prepare(`SELECT id, file_id, transaction_id, account_id, kind, prompt, options_json, context_json, created_at
|
|
89
89
|
FROM unknowns
|
|
90
90
|
WHERE resolved_at IS NULL
|
|
91
91
|
ORDER BY created_at ASC
|
|
@@ -106,7 +106,7 @@ export function listOpenUnknownsByKind(db, kinds, limit = 50) {
|
|
|
106
106
|
const capped = Math.min(Math.max(limit, 1), 200);
|
|
107
107
|
const placeholders = kinds.map(() => "?").join(",");
|
|
108
108
|
const cases = kinds.map((_, i) => `WHEN ? THEN ${i}`).join(" ");
|
|
109
|
-
return db.prepare(`SELECT id, file_id, transaction_id, account_id, kind, prompt, options_json, created_at
|
|
109
|
+
return db.prepare(`SELECT id, file_id, transaction_id, account_id, kind, prompt, options_json, context_json, created_at
|
|
110
110
|
FROM unknowns
|
|
111
111
|
WHERE resolved_at IS NULL AND kind IN (${placeholders})
|
|
112
112
|
ORDER BY CASE kind ${cases} ELSE ${kinds.length} END, created_at ASC
|
package/dist/db/schema.js
CHANGED
|
@@ -106,6 +106,7 @@ export function migrate(db) {
|
|
|
106
106
|
kind TEXT,
|
|
107
107
|
prompt TEXT NOT NULL,
|
|
108
108
|
options_json TEXT,
|
|
109
|
+
context_json TEXT,
|
|
109
110
|
answer TEXT,
|
|
110
111
|
resolved_at TEXT,
|
|
111
112
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
@@ -157,4 +158,11 @@ export function migrate(db) {
|
|
|
157
158
|
CREATE INDEX IF NOT EXISTS action_log_correlation_idx ON action_log(correlation_id);
|
|
158
159
|
CREATE INDEX IF NOT EXISTS action_log_created_idx ON action_log(created_at);
|
|
159
160
|
`);
|
|
161
|
+
ensureColumn(db, "unknowns", "context_json", "TEXT");
|
|
162
|
+
}
|
|
163
|
+
function ensureColumn(db, table, column, type) {
|
|
164
|
+
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
165
|
+
if (cols.some((c) => c.name === column))
|
|
166
|
+
return;
|
|
167
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
|
|
160
168
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic "drive a loop with named hooks" helper.
|
|
3
|
+
*
|
|
4
|
+
* The driver owns counting passes, stall detection, and the iteration cap.
|
|
5
|
+
* Everything else (work performed each pass, what to print when, how to react
|
|
6
|
+
* to stall vs success vs failure) lives in the hooks the caller supplies.
|
|
7
|
+
*
|
|
8
|
+
* The state `S` is whatever quantity decides "are we done?" — typically a
|
|
9
|
+
* remaining-work count, but it can be any value you can compare.
|
|
10
|
+
*/
|
|
11
|
+
export interface RunPassesOpts<S> {
|
|
12
|
+
/** Initial state (e.g. `countOpenUnknowns(db)`). */
|
|
13
|
+
initial: S;
|
|
14
|
+
/** Maximum number of passes before declaring failure. Must be >= 1. */
|
|
15
|
+
maxAttempts: number;
|
|
16
|
+
/** True when the work is finished and the loop should stop cleanly. */
|
|
17
|
+
isDone: (state: S) => boolean;
|
|
18
|
+
/**
|
|
19
|
+
* True when this pass made no progress vs the previous pass. Fires after
|
|
20
|
+
* the first pass at the earliest.
|
|
21
|
+
*/
|
|
22
|
+
isStalled: (curr: S, prev: S) => boolean;
|
|
23
|
+
/** Run one pass; return the new state. Pass numbers are 1-indexed. */
|
|
24
|
+
onPass: (pass: number, state: S) => Promise<S>;
|
|
25
|
+
onStart?: (state: S) => void;
|
|
26
|
+
onStall?: (state: S) => void;
|
|
27
|
+
onSuccess?: (state: S) => void;
|
|
28
|
+
onFail?: (state: S) => void;
|
|
29
|
+
}
|
|
30
|
+
export declare function runPasses<S>(opts: RunPassesOpts<S>): Promise<S>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export async function runPasses(opts) {
|
|
2
|
+
let state = opts.initial;
|
|
3
|
+
let prev = state;
|
|
4
|
+
opts.onStart?.(state);
|
|
5
|
+
for (let pass = 1; pass <= opts.maxAttempts && !opts.isDone(state); pass++) {
|
|
6
|
+
if (pass > 1 && opts.isStalled(state, prev)) {
|
|
7
|
+
opts.onStall?.(state);
|
|
8
|
+
return state;
|
|
9
|
+
}
|
|
10
|
+
prev = state;
|
|
11
|
+
state = await opts.onPass(pass, state);
|
|
12
|
+
}
|
|
13
|
+
(opts.isDone(state) ? opts.onSuccess : opts.onFail)?.(state);
|
|
14
|
+
return state;
|
|
15
|
+
}
|
|
@@ -3,14 +3,14 @@ export interface ResolveOptions {
|
|
|
3
3
|
from?: string;
|
|
4
4
|
to?: string;
|
|
5
5
|
kind?: string;
|
|
6
|
-
|
|
7
|
-
/** Hard cap on unknowns handed to the agent in one run. Default 200. */
|
|
6
|
+
/** Hard cap on unknowns handed to the agent in one pass. Default 200. */
|
|
8
7
|
limit?: number;
|
|
9
8
|
}
|
|
10
9
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* Drain every open unknown by looping the resolve agent until the DB says
|
|
11
|
+
* we're done. Completion and stall detection both read from
|
|
12
|
+
* `countOpenUnknowns(db)` — the LLM has no "I'm done" signal; we trust state,
|
|
13
|
+
* not narration. The loop driver (`runPasses`) owns counting / cap / stall;
|
|
14
|
+
* the hooks below own everything else.
|
|
15
15
|
*/
|
|
16
16
|
export declare function runResolve(opts?: ResolveOptions): Promise<string>;
|
|
@@ -1,38 +1,66 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
1
2
|
import { getDb } from "../db/connection.js";
|
|
2
3
|
import { runResolveAgent } from "../ai/agent.js";
|
|
3
|
-
import { listOpenUnknowns, listOpenUnknownsByKind } from "../db/queries/unknowns.js";
|
|
4
|
+
import { countOpenUnknowns, listOpenUnknowns, listOpenUnknownsByKind, } from "../db/queries/unknowns.js";
|
|
4
5
|
import { statusSpinner, makePromptUser, makeAgentOnProgress, } from "../cli/ux.js";
|
|
6
|
+
import { runPasses } from "../lib/runPasses.js";
|
|
5
7
|
import { buildResolveUserMessage } from "./prompts.js";
|
|
8
|
+
const MAX_PASSES = 3;
|
|
6
9
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* Drain every open unknown by looping the resolve agent until the DB says
|
|
11
|
+
* we're done. Completion and stall detection both read from
|
|
12
|
+
* `countOpenUnknowns(db)` — the LLM has no "I'm done" signal; we trust state,
|
|
13
|
+
* not narration. The loop driver (`runPasses`) owns counting / cap / stall;
|
|
14
|
+
* the hooks below own everything else.
|
|
11
15
|
*/
|
|
12
16
|
export async function runResolve(opts = {}) {
|
|
13
17
|
const db = getDb();
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
const promptUser = interactive ? makePromptUser(spinner) : undefined;
|
|
22
|
-
let summary = "";
|
|
18
|
+
const scope = opts.kind ? { kind: opts.kind } : {};
|
|
19
|
+
const startOpen = countOpenUnknowns(db, scope);
|
|
20
|
+
if (startOpen === 0)
|
|
21
|
+
return "No unknowns to resolve.";
|
|
22
|
+
const spinner = statusSpinner(`Resolving ${startOpen} unknown(s)...`);
|
|
23
|
+
const promptUser = makePromptUser(spinner);
|
|
24
|
+
const onProgress = makeAgentOnProgress(spinner);
|
|
23
25
|
try {
|
|
24
|
-
await
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
const finalOpen = await runPasses({
|
|
27
|
+
initial: startOpen,
|
|
28
|
+
maxAttempts: MAX_PASSES,
|
|
29
|
+
isDone: (open) => open === 0,
|
|
30
|
+
isStalled: (curr, prev) => curr >= prev,
|
|
31
|
+
onPass: async (pass, open) => {
|
|
32
|
+
spinner.text = `Resolving ${open} unknown(s) — pass ${pass}...`;
|
|
33
|
+
await runResolveAgent({
|
|
34
|
+
db,
|
|
35
|
+
prompt: { accountId: opts.accountId, from: opts.from, to: opts.to },
|
|
36
|
+
initialMessages: [
|
|
37
|
+
{ role: "user", content: buildResolveUserMessage(listFor(db, opts, scope)) },
|
|
38
|
+
],
|
|
39
|
+
agentCtx: { interactive: true, promptUser },
|
|
40
|
+
onProgress,
|
|
41
|
+
});
|
|
42
|
+
return countOpenUnknowns(db, scope);
|
|
43
|
+
},
|
|
44
|
+
onStall: (open) => spinner.info(`Resolve made no progress (${open} left). Re-run \`plasalid resolve\` or inspect the data.`),
|
|
45
|
+
onSuccess: () => spinner.succeed("Resolve done."),
|
|
46
|
+
onFail: (open) => spinner.fail(`Resolve left ${open} unknown(s) open after ${MAX_PASSES} pass(es).`),
|
|
30
47
|
});
|
|
31
|
-
|
|
48
|
+
return summarize(startOpen, finalOpen);
|
|
32
49
|
}
|
|
33
50
|
catch (err) {
|
|
34
51
|
spinner.fail(`Resolve failed: ${err.message}`);
|
|
35
52
|
throw err;
|
|
36
53
|
}
|
|
37
|
-
|
|
54
|
+
}
|
|
55
|
+
function listFor(db, opts, scope) {
|
|
56
|
+
const limit = opts.limit ?? 200;
|
|
57
|
+
return scope.kind
|
|
58
|
+
? listOpenUnknownsByKind(db, [scope.kind], limit)
|
|
59
|
+
: listOpenUnknowns(db, limit);
|
|
60
|
+
}
|
|
61
|
+
function summarize(startOpen, stillOpen) {
|
|
62
|
+
const resolved = startOpen - stillOpen;
|
|
63
|
+
if (stillOpen === 0)
|
|
64
|
+
return chalk.green(`Resolved ${resolved} unknown(s).`);
|
|
65
|
+
return chalk.yellow(`Resolved ${resolved} of ${startOpen}; ${stillOpen} still open.`);
|
|
38
66
|
}
|
|
@@ -24,42 +24,40 @@ function inspect(db, scope) {
|
|
|
24
24
|
transaction_id: null,
|
|
25
25
|
account_id: pair.a.id,
|
|
26
26
|
kind: "similar_accounts",
|
|
27
|
-
prompt: `These two accounts look like the same thing
|
|
27
|
+
prompt: `These two accounts look like the same thing:\n ${pair.a.name}\n ${pair.b.name}`,
|
|
28
28
|
options: [
|
|
29
|
-
`Merge ${pair.b.
|
|
30
|
-
`Merge ${pair.a.
|
|
29
|
+
`Merge ${pair.b.name} into ${pair.a.name}`,
|
|
30
|
+
`Merge ${pair.a.name} into ${pair.b.name}`,
|
|
31
31
|
"Keep separate",
|
|
32
32
|
"Skip",
|
|
33
33
|
],
|
|
34
|
+
context: { otherAccountId: pair.b.id },
|
|
34
35
|
});
|
|
35
36
|
}
|
|
36
37
|
return out;
|
|
37
38
|
}
|
|
38
39
|
/**
|
|
39
|
-
* `similar_accounts` unknowns
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
* `similar_accounts` unknowns store the partner account id in `context_json`
|
|
41
|
+
* (`{otherAccountId}`); the row's own `account_id` is one half of the pair.
|
|
42
|
+
* Read both to skip pairs the user has already seen — including ones answered
|
|
43
|
+
* "Keep separate" on a prior run.
|
|
43
44
|
*/
|
|
44
45
|
function loadAlreadyFlaggedAccountPairs(db) {
|
|
45
46
|
const rows = db
|
|
46
|
-
.prepare(`SELECT account_id,
|
|
47
|
+
.prepare(`SELECT account_id, context_json FROM unknowns
|
|
47
48
|
WHERE kind = 'similar_accounts' AND account_id IS NOT NULL`)
|
|
48
49
|
.all();
|
|
49
50
|
const out = new Set();
|
|
50
51
|
for (const row of rows) {
|
|
51
|
-
if (!row.
|
|
52
|
+
if (!row.context_json)
|
|
52
53
|
continue;
|
|
53
54
|
try {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (match)
|
|
58
|
-
out.add(pairKey(match[1], match[2]));
|
|
59
|
-
}
|
|
55
|
+
const ctx = JSON.parse(row.context_json);
|
|
56
|
+
if (ctx.otherAccountId)
|
|
57
|
+
out.add(pairKey(row.account_id, ctx.otherAccountId));
|
|
60
58
|
}
|
|
61
59
|
catch {
|
|
62
|
-
// skip malformed
|
|
60
|
+
// skip malformed context_json
|
|
63
61
|
}
|
|
64
62
|
}
|
|
65
63
|
return out;
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plasalid",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Plasalid — The Harness Layer for Personal Finance",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"finance",
|
|
7
7
|
"harness",
|
|
8
|
-
"personal
|
|
8
|
+
"personal",
|
|
9
9
|
"aggregator",
|
|
10
10
|
"parser",
|
|
11
11
|
"cli",
|