plasalid 0.6.10 → 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.
Files changed (56) hide show
  1. package/README.md +3 -5
  2. package/dist/accounts/taxonomy.d.ts +0 -23
  3. package/dist/accounts/taxonomy.js +15 -15
  4. package/dist/ai/agent.d.ts +4 -4
  5. package/dist/ai/agent.js +9 -8
  6. package/dist/ai/context.d.ts +0 -2
  7. package/dist/ai/context.js +2 -2
  8. package/dist/ai/memory.d.ts +1 -0
  9. package/dist/ai/memory.js +4 -0
  10. package/dist/ai/personas.js +3 -6
  11. package/dist/ai/provider.d.ts +1 -0
  12. package/dist/ai/thinking.d.ts +0 -6
  13. package/dist/ai/thinking.js +29 -4
  14. package/dist/ai/tools/index.d.ts +5 -1
  15. package/dist/ai/tools/index.js +21 -15
  16. package/dist/ai/tools/ingest.js +94 -110
  17. package/dist/ai/tools/resolve.js +15 -44
  18. package/dist/cli/commands/accounts.d.ts +4 -1
  19. package/dist/cli/commands/accounts.js +39 -20
  20. package/dist/cli/commands/scan.js +47 -47
  21. package/dist/cli/commands/status.js +81 -14
  22. package/dist/cli/commands/transactions.d.ts +3 -1
  23. package/dist/cli/commands/transactions.js +37 -34
  24. package/dist/cli/format.d.ts +0 -1
  25. package/dist/cli/format.js +1 -1
  26. package/dist/cli/helper.d.ts +11 -0
  27. package/dist/cli/helper.js +24 -0
  28. package/dist/cli/index.js +14 -10
  29. package/dist/cli/ink/AccountsBrowser.d.ts +7 -0
  30. package/dist/cli/ink/AccountsBrowser.js +149 -0
  31. package/dist/cli/ink/ListBrowser.d.ts +38 -0
  32. package/dist/cli/ink/ListBrowser.js +154 -0
  33. package/dist/cli/ink/TransactionsBrowser.d.ts +6 -0
  34. package/dist/cli/ink/TransactionsBrowser.js +87 -0
  35. package/dist/cli/ink/hooks/useFooterText.js +31 -14
  36. package/dist/cli/ink/runBrowser.d.ts +7 -0
  37. package/dist/cli/ink/runBrowser.js +24 -0
  38. package/dist/cli/ux.d.ts +4 -5
  39. package/dist/cli/ux.js +87 -66
  40. package/dist/db/connection.d.ts +0 -2
  41. package/dist/db/connection.js +0 -5
  42. package/dist/db/queries/files.d.ts +11 -0
  43. package/dist/db/queries/files.js +16 -0
  44. package/dist/db/queries/recurrences.d.ts +7 -0
  45. package/dist/db/queries/recurrences.js +21 -0
  46. package/dist/db/queries/transactions.d.ts +28 -4
  47. package/dist/db/queries/transactions.js +68 -15
  48. package/dist/db/queries/unknowns.d.ts +3 -5
  49. package/dist/db/queries/unknowns.js +4 -4
  50. package/dist/db/schema.js +8 -0
  51. package/dist/lib/runPasses.d.ts +30 -0
  52. package/dist/lib/runPasses.js +15 -0
  53. package/dist/resolver/pipeline.d.ts +6 -6
  54. package/dist/resolver/pipeline.js +50 -22
  55. package/dist/scanner/inspectors/similarities.js +14 -16
  56. 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 balance + invariants and assign an id. Pure (no DB writes). Used by
46
- * both `recordTransaction` and the buffered-scan commit path; the latter
47
- * already runs inside its own transaction and must not open another.
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
- const TOLERANCE = 0.005;
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 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.
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 < 2) {
22
- throw new Error("Transaction must contain at least two postings.");
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 input.postings) {
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
- export function maybeClearHasUnknownFlags(db, target) {
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
- interactive?: boolean;
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
- * Hand every open unknown to the resolve agent in a single invocation. The
12
- * agent surveys, applies memory-driven and heuristic resolutions silently,
13
- * groups what remains, asks the user once per group, and reports back via
14
- * mark_resolve_done. The pipeline just sets up plumbing and prints the report.
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
- * Hand every open unknown to the resolve agent in a single invocation. The
8
- * agent surveys, applies memory-driven and heuristic resolutions silently,
9
- * groups what remains, asks the user once per group, and reports back via
10
- * mark_resolve_done. The pipeline just sets up plumbing and prints the report.
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 unknowns = opts.kind
15
- ? listOpenUnknownsByKind(db, [opts.kind], opts.limit ?? 200)
16
- : listOpenUnknowns(db, opts.limit ?? 200);
17
- if (unknowns.length === 0)
18
- return "No open unknowns.";
19
- const interactive = opts.interactive ?? true;
20
- const spinner = statusSpinner(`Resolving ${unknowns.length} unknown(s)...`);
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 runResolveAgent({
25
- db,
26
- prompt: { accountId: opts.accountId, from: opts.from, to: opts.to },
27
- initialMessages: [{ role: "user", content: buildResolveUserMessage(unknowns) }],
28
- agentCtx: { interactive, promptUser, onComplete: (s) => { summary = s; } },
29
- onProgress: makeAgentOnProgress(spinner),
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
- spinner.succeed("Resolve done.");
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
- return summary;
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 (similarity ${pair.similarity}):\n ${pair.a.id} — ${pair.a.name}\n ${pair.b.id} — ${pair.b.name}`,
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.id} into ${pair.a.id}`,
30
- `Merge ${pair.a.id} into ${pair.b.id}`,
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 (open OR resolved) embed the other account's id
40
- * in their options strings ("Merge X into Y"). Parse those out so we don't
41
- * re-flag a pair the user has already seen — including pairs they've already
42
- * answered "Keep separate" on a prior run.
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, options_json FROM unknowns
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.options_json)
52
+ if (!row.context_json)
52
53
  continue;
53
54
  try {
54
- const options = JSON.parse(row.options_json);
55
- for (const opt of options) {
56
- const match = opt.match(/Merge (\S+) into (\S+)/);
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 options_json
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.6.10",
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-finance",
8
+ "personal",
9
9
  "aggregator",
10
10
  "parser",
11
11
  "cli",