plasalid 0.5.7 → 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 (97) 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 +8 -9
  5. package/dist/ai/agent.js +21 -20
  6. package/dist/ai/errors.d.ts +16 -0
  7. package/dist/ai/errors.js +47 -0
  8. package/dist/ai/personas.d.ts +1 -1
  9. package/dist/ai/personas.js +69 -66
  10. package/dist/ai/prompt-sections.d.ts +4 -5
  11. package/dist/ai/prompt-sections.js +11 -11
  12. package/dist/ai/providers/anthropic.js +10 -4
  13. package/dist/ai/providers/openai.js +70 -56
  14. package/dist/ai/redactor.js +77 -51
  15. package/dist/ai/system-prompt.d.ts +2 -3
  16. package/dist/ai/system-prompt.js +5 -5
  17. package/dist/ai/tools/common.js +13 -5
  18. package/dist/ai/tools/index.js +15 -15
  19. package/dist/ai/tools/ingest.d.ts +2 -2
  20. package/dist/ai/tools/ingest.js +210 -87
  21. package/dist/ai/tools/merchants.js +27 -12
  22. package/dist/ai/tools/read.js +36 -20
  23. package/dist/ai/tools/record.js +79 -19
  24. package/dist/ai/tools/resolve.d.ts +2 -0
  25. package/dist/ai/tools/resolve.js +195 -0
  26. package/dist/ai/tools/types.d.ts +5 -7
  27. package/dist/cli/commands/accounts.js +2 -2
  28. package/dist/cli/commands/record.js +4 -2
  29. package/dist/cli/commands/resolve.d.ts +2 -0
  30. package/dist/cli/commands/resolve.js +13 -0
  31. package/dist/cli/commands/scan.js +18 -22
  32. package/dist/cli/commands/status.js +4 -2
  33. package/dist/cli/index.js +9 -9
  34. package/dist/cli/ink/hooks/useFooterText.js +1 -1
  35. package/dist/cli/ink/hooks/useTextInput.js +60 -69
  36. package/dist/cli/ink/scan_dashboard.d.ts +2 -2
  37. package/dist/cli/ink/scan_dashboard.js +3 -3
  38. package/dist/cli/setup.js +6 -3
  39. package/dist/cli/ux.js +1 -1
  40. package/dist/db/queries/account-balance.d.ts +140 -0
  41. package/dist/db/queries/account-balance.js +355 -0
  42. package/dist/db/queries/account_balance.d.ts +0 -1
  43. package/dist/db/queries/account_balance.js +0 -10
  44. package/dist/db/queries/action-log.d.ts +29 -0
  45. package/dist/db/queries/action-log.js +27 -0
  46. package/dist/db/queries/action_log.d.ts +1 -1
  47. package/dist/db/queries/concerns.d.ts +10 -0
  48. package/dist/db/queries/concerns.js +21 -0
  49. package/dist/db/queries/transactions.d.ts +3 -22
  50. package/dist/db/queries/transactions.js +4 -5
  51. package/dist/db/queries/unknowns.d.ts +62 -0
  52. package/dist/db/queries/unknowns.js +114 -0
  53. package/dist/db/schema.js +3 -3
  54. package/dist/resolver/pipeline.d.ts +16 -0
  55. package/dist/resolver/pipeline.js +38 -0
  56. package/dist/resolver/prompts.d.ts +8 -0
  57. package/dist/resolver/prompts.js +26 -0
  58. package/dist/scanner/account-mutex.d.ts +1 -0
  59. package/dist/scanner/account-mutex.js +16 -0
  60. package/dist/scanner/buffer.d.ts +10 -10
  61. package/dist/scanner/buffer.js +15 -15
  62. package/dist/scanner/concurrency.d.ts +10 -7
  63. package/dist/scanner/concurrency.js +3 -16
  64. package/dist/scanner/decrypt-queue.d.ts +57 -0
  65. package/dist/scanner/decrypt-queue.js +114 -0
  66. package/dist/scanner/decrypt_queue.js +56 -38
  67. package/dist/scanner/detectors/correlations.d.ts +2 -0
  68. package/dist/scanner/detectors/correlations.js +51 -0
  69. package/dist/scanner/detectors/duplicates.d.ts +2 -0
  70. package/dist/scanner/detectors/duplicates.js +75 -0
  71. package/dist/scanner/detectors/index.d.ts +18 -0
  72. package/dist/scanner/detectors/index.js +39 -0
  73. package/dist/scanner/detectors/recurrences.d.ts +2 -0
  74. package/dist/scanner/detectors/recurrences.js +49 -0
  75. package/dist/scanner/detectors/similar_accounts.d.ts +2 -0
  76. package/dist/scanner/detectors/similar_accounts.js +64 -0
  77. package/dist/scanner/detectors/similarities.d.ts +2 -0
  78. package/dist/scanner/detectors/similarities.js +73 -0
  79. package/dist/scanner/detectors/types.d.ts +16 -0
  80. package/dist/scanner/detectors/types.js +1 -0
  81. package/dist/scanner/inspectors/correlations.d.ts +2 -0
  82. package/dist/scanner/inspectors/correlations.js +47 -0
  83. package/dist/scanner/inspectors/duplicates.d.ts +2 -0
  84. package/dist/scanner/inspectors/duplicates.js +75 -0
  85. package/dist/scanner/inspectors/index.d.ts +19 -0
  86. package/dist/scanner/inspectors/index.js +39 -0
  87. package/dist/scanner/inspectors/recurrences.d.ts +2 -0
  88. package/dist/scanner/inspectors/recurrences.js +49 -0
  89. package/dist/scanner/inspectors/similarities.d.ts +2 -0
  90. package/dist/scanner/inspectors/similarities.js +73 -0
  91. package/dist/scanner/inspectors/types.d.ts +16 -0
  92. package/dist/scanner/inspectors/types.js +1 -0
  93. package/dist/scanner/pdf-unlock.js +3 -1
  94. package/dist/scanner/pipeline.d.ts +6 -4
  95. package/dist/scanner/pipeline.js +63 -102
  96. package/dist/scanner/prompts.js +2 -2
  97. package/package.json +2 -1
@@ -0,0 +1,19 @@
1
+ import type Database from "libsql";
2
+ import type { Inspector, InspectorScope } from "./types.js";
3
+ export type { Inspector, InspectorScope } from "./types.js";
4
+ /**
5
+ * The ordered list of post-commit inspectors the scanner runs. Order matters
6
+ * only for the resolver's priority sweep, not for correctness — each inspector
7
+ * emits unknowns independently of the others.
8
+ */
9
+ export declare const inspectors: readonly Inspector[];
10
+ export interface InspectionRunResult {
11
+ total: number;
12
+ byInspector: Record<string, number>;
13
+ }
14
+ /**
15
+ * Run every inspector in order and insert any unknowns they produce. Returns
16
+ * counts so the CLI can report "X unknowns surfaced." Failure of one inspector
17
+ * never aborts the run — it logs and the others still execute.
18
+ */
19
+ export declare function runInspectors(db: Database.Database, scope: InspectorScope): InspectionRunResult;
@@ -0,0 +1,39 @@
1
+ import { recordUnknown } from "../../db/queries/unknowns.js";
2
+ import { duplicatesInspector } from "./duplicates.js";
3
+ import { correlationsInspector } from "./correlations.js";
4
+ import { recurrencesInspector } from "./recurrences.js";
5
+ import { similarAccountsInspector } from "./similarities.js";
6
+ /**
7
+ * The ordered list of post-commit inspectors the scanner runs. Order matters
8
+ * only for the resolver's priority sweep, not for correctness — each inspector
9
+ * emits unknowns independently of the others.
10
+ */
11
+ export const inspectors = [
12
+ duplicatesInspector,
13
+ correlationsInspector,
14
+ recurrencesInspector,
15
+ similarAccountsInspector,
16
+ ];
17
+ /**
18
+ * Run every inspector in order and insert any unknowns they produce. Returns
19
+ * counts so the CLI can report "X unknowns surfaced." Failure of one inspector
20
+ * never aborts the run — it logs and the others still execute.
21
+ */
22
+ export function runInspectors(db, scope) {
23
+ const byInspector = {};
24
+ let total = 0;
25
+ for (const inspector of inspectors) {
26
+ try {
27
+ const unknowns = inspector.inspect(db, scope);
28
+ for (const u of unknowns)
29
+ recordUnknown(db, u);
30
+ byInspector[inspector.name] = unknowns.length;
31
+ total += unknowns.length;
32
+ }
33
+ catch (err) {
34
+ byInspector[inspector.name] = 0;
35
+ console.error(`[inspector ${inspector.name}] ${err?.message ?? err}`);
36
+ }
37
+ }
38
+ return { total, byInspector };
39
+ }
@@ -0,0 +1,2 @@
1
+ import type { Inspector } from "./types.js";
2
+ export declare const recurrencesInspector: Inspector;
@@ -0,0 +1,49 @@
1
+ import { findRecurrenceCandidates } from "../../db/queries/recurrences.js";
2
+ import { formatAmount } from "../../currency.js";
3
+ /**
4
+ * Surface recurrence candidates whose latest sighting landed in this scan run.
5
+ * One unknown per candidate, attached to the most recent transaction in the
6
+ * group. Skips candidates whose median interval is "irregular" — those are
7
+ * unlikely to be real subscriptions and surfacing them just creates noise.
8
+ */
9
+ function inspect(db, scope) {
10
+ if (scope.fileIds.length === 0)
11
+ return [];
12
+ const candidates = findRecurrenceCandidates(db);
13
+ if (candidates.length === 0)
14
+ return [];
15
+ const inScope = transactionsInScope(db, scope.fileIds);
16
+ const out = [];
17
+ for (const candidate of candidates) {
18
+ if (candidate.implied_frequency === "irregular")
19
+ continue;
20
+ const latest = candidate.transactions[candidate.transactions.length - 1];
21
+ if (!inScope.has(latest.id))
22
+ continue;
23
+ out.push({
24
+ file_id: null,
25
+ transaction_id: latest.id,
26
+ account_id: candidate.account_id,
27
+ kind: "recurrence_candidate",
28
+ prompt: buildPrompt(candidate, latest),
29
+ options: ["Link as recurring", "Not recurring", "Skip"],
30
+ });
31
+ }
32
+ return out;
33
+ }
34
+ function buildPrompt(candidate, latest) {
35
+ const amount = formatAmount(candidate.amount, candidate.currency);
36
+ const occurrences = candidate.transactions.length;
37
+ return [
38
+ `Possible ${candidate.implied_frequency} recurrence on ${candidate.account_name}: ${amount} (${occurrences} sightings, median ${candidate.median_days_between} days apart).`,
39
+ `Latest: ${latest.date} — ${latest.description}`,
40
+ ].join("\n");
41
+ }
42
+ function transactionsInScope(db, fileIds) {
43
+ const placeholders = fileIds.map(() => "?").join(",");
44
+ const rows = db
45
+ .prepare(`SELECT id FROM transactions WHERE source_file_id IN (${placeholders})`)
46
+ .all(...fileIds);
47
+ return new Set(rows.map(r => r.id));
48
+ }
49
+ export const recurrencesInspector = { name: "recurrences", inspect };
@@ -0,0 +1,2 @@
1
+ import type { Inspector } from "./types.js";
2
+ export declare const similarAccountsInspector: Inspector;
@@ -0,0 +1,73 @@
1
+ import { findSimilarAccounts } from "../../db/queries/account-balance.js";
2
+ /**
3
+ * Flag pairs of accounts whose names are near-identical (Levenshtein ≥ 0.85).
4
+ * Runs whenever a scan committed at least one transaction — the assumption is
5
+ * that the scanner may have created a new account this run, so it's worth a
6
+ * fresh similarity sweep. Idempotent against existing open unknowns: a pair
7
+ * already flagged is not flagged again. The resolver applies "Merge A into B"
8
+ * via merge_accounts.
9
+ */
10
+ function inspect(db, scope) {
11
+ if (scope.fileIds.length === 0)
12
+ return [];
13
+ const pairs = findSimilarAccounts(db);
14
+ if (pairs.length === 0)
15
+ return [];
16
+ const alreadyFlagged = loadAlreadyFlaggedAccountPairs(db);
17
+ const out = [];
18
+ for (const pair of pairs) {
19
+ const key = pairKey(pair.a.id, pair.b.id);
20
+ if (alreadyFlagged.has(key))
21
+ continue;
22
+ out.push({
23
+ file_id: null,
24
+ transaction_id: null,
25
+ account_id: pair.a.id,
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}`,
28
+ options: [
29
+ `Merge ${pair.b.id} into ${pair.a.id}`,
30
+ `Merge ${pair.a.id} into ${pair.b.id}`,
31
+ "Keep separate",
32
+ "Skip",
33
+ ],
34
+ });
35
+ }
36
+ return out;
37
+ }
38
+ /**
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.
43
+ */
44
+ function loadAlreadyFlaggedAccountPairs(db) {
45
+ const rows = db
46
+ .prepare(`SELECT account_id, options_json FROM unknowns
47
+ WHERE kind = 'similar_accounts' AND account_id IS NOT NULL`)
48
+ .all();
49
+ const out = new Set();
50
+ for (const row of rows) {
51
+ if (!row.options_json)
52
+ continue;
53
+ 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
+ }
60
+ }
61
+ catch {
62
+ // skip malformed options_json
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+ function pairKey(a, b) {
68
+ return [a, b].sort().join("|");
69
+ }
70
+ export const similarAccountsInspector = {
71
+ name: "similar_accounts",
72
+ inspect,
73
+ };
@@ -0,0 +1,16 @@
1
+ import type Database from "libsql";
2
+ import type { RecordUnknownInput } from "../../db/queries/unknowns.js";
3
+ /**
4
+ * Scope passed to every inspector by the scanner's Phase 5. Inspectors emit
5
+ * unknowns for transactions whose `source_file_id` is in `fileIds` (or for
6
+ * cross-pair findings where at least one side lives in that set). Inspectors
7
+ * are free to read the wider DB for context — the scope is a filter for what
8
+ * to surface, not a limit on what to read.
9
+ */
10
+ export interface InspectorScope {
11
+ readonly fileIds: readonly string[];
12
+ }
13
+ export interface Inspector {
14
+ readonly name: string;
15
+ inspect(db: Database.Database, scope: InspectorScope): RecordUnknownInput[];
16
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -3,6 +3,8 @@
3
3
  * the WASM module isn't loaded for data dirs that contain only plaintext PDFs.
4
4
  */
5
5
  let mupdfPromise = null;
6
+ /** mupdf's authenticatePassword returns 0 on a wrong password, non-zero on success. */
7
+ const MUPDF_AUTH_FAILED = 0;
6
8
  function getMupdf() {
7
9
  if (!mupdfPromise) {
8
10
  mupdfPromise = import("mupdf");
@@ -36,7 +38,7 @@ export async function unlock(bytes, password) {
36
38
  return { ok: true, decrypted: bytes };
37
39
  }
38
40
  const result = doc.authenticatePassword(password);
39
- if (result === 0) {
41
+ if (result === MUPDF_AUTH_FAILED) {
40
42
  return { ok: false };
41
43
  }
42
44
  const out = doc.saveToBuffer("decrypt");
@@ -1,10 +1,11 @@
1
+ import { type InspectionRunResult } from "./inspectors/index.js";
1
2
  export type ScanFileStatus = "scanned" | "replaced" | "failed" | "skipped";
2
3
  export interface ScanFileResult {
3
4
  name: string;
4
5
  relPath: string;
5
6
  status: ScanFileStatus;
6
7
  transactions: number;
7
- concerns: number;
8
+ unknowns: number;
8
9
  error?: string;
9
10
  }
10
11
  export interface ScanSummary {
@@ -13,7 +14,7 @@ export interface ScanSummary {
13
14
  replaced: number;
14
15
  skipped: number;
15
16
  failed: number;
16
- concerns: number;
17
+ unknowns: number;
17
18
  details: ScanFileResult[];
18
19
  }
19
20
  /** Event hooks the CLI subscribes to. All callbacks are best-effort and ignored if absent. */
@@ -41,11 +42,12 @@ export interface ScanRunEvents {
41
42
  fileName: string;
42
43
  status: "scanned" | "failed";
43
44
  transactions: number;
44
- concerns: number;
45
+ unknowns: number;
45
46
  error?: string;
46
47
  }) => void;
47
- correlating?: (pairs: number) => void;
48
48
  committing?: () => void;
49
+ /** Post-commit inspector pass. `result.total` is the count of unknowns emitted by all inspectors combined. */
50
+ inspecting?: (result: InspectionRunResult) => void;
49
51
  }
50
52
  export interface RunScanOptions {
51
53
  regex?: string;
@@ -1,14 +1,14 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import { getDb } from "../db/connection.js";
3
- import { countOpenConcerns, } from "../db/queries/concerns.js";
4
- import { correlatePairs } from "../db/queries/transactions.js";
3
+ import { countOpenUnknowns } from "../db/queries/unknowns.js";
4
+ import { runInspectors } from "./inspectors/index.js";
5
5
  import { runScanAgent } from "../ai/agent.js";
6
6
  import { buildDocumentBlock } from "./pdf.js";
7
7
  import { buildScanUserMessage } from "./prompts.js";
8
8
  import { scanDataDir } from "./walker.js";
9
9
  import { BufferedWriteContext } from "./buffer.js";
10
10
  import { runWithConcurrency } from "./concurrency.js";
11
- import { decryptQueue, confirmProceedAfterFailures, } from "./decrypt_queue.js";
11
+ import { decryptQueue, confirmProceedAfterFailures, } from "./decrypt-queue.js";
12
12
  export function compileMatcher(input) {
13
13
  return new RegExp(input, "i");
14
14
  }
@@ -38,30 +38,50 @@ export async function runScan(opts = {}) {
38
38
  }
39
39
  // Phase 2 — parallel scan with buffered writes
40
40
  const scanResults = await scanInParallel(db, decryptResult.decrypted, { concurrency, events });
41
- // Phase 3 — cross-file correlation pre-commit
42
- const pairCount = applyCrossFileCorrelations(scanResults);
43
- events?.correlating?.(pairCount);
44
- // Phase 4 — per-file commit
41
+ // Phase 3 — per-file commit
45
42
  events?.committing?.();
46
- const fileResults = commitAll(db, decryptResult, scanResults);
47
- return buildSummary(allFiles.length, fileResults, decryptResult);
43
+ const { details, committedFileIds } = commitAll(db, decryptResult, scanResults);
44
+ // Phase 4 — post-commit inspector sweep (duplicates, correlations, recurrences, similar accounts)
45
+ if (committedFileIds.length > 0) {
46
+ const inspectionResult = runInspectors(db, { fileIds: committedFileIds });
47
+ events?.inspecting?.(inspectionResult);
48
+ addInspectionUnknownsToSummary(details, committedFileIds, inspectionResult.total);
49
+ }
50
+ return buildSummary(allFiles.length, details);
51
+ }
52
+ /**
53
+ * Inspector unknowns were inserted after the per-file commit, so the per-file
54
+ * `unknowns` counters in `details` don't see them. Spread the total across the
55
+ * files that participated in this run so the summary's `unknowns` line stays
56
+ * truthful. Distribution is per-file proportional — good enough for a summary,
57
+ * not a load-bearing fact.
58
+ */
59
+ function addInspectionUnknownsToSummary(details, committedFileIds, total) {
60
+ if (total === 0 || committedFileIds.length === 0)
61
+ return;
62
+ const scannedDetails = details.filter(d => d.status === "scanned" || d.status === "replaced");
63
+ if (scannedDetails.length === 0)
64
+ return;
65
+ const perFile = Math.floor(total / scannedDetails.length);
66
+ const remainder = total - perFile * scannedDetails.length;
67
+ for (let i = 0; i < scannedDetails.length; i++) {
68
+ scannedDetails[i].unknowns += perFile + (i < remainder ? 1 : 0);
69
+ }
48
70
  }
49
71
  async function scanInParallel(db, files, opts) {
50
72
  const tasks = files.map(f => () => scanOneFile(db, f, opts.events));
51
73
  const settled = await runWithConcurrency(tasks, opts.concurrency);
52
- // Worker errors are captured per-slot by runWithConcurrency. scanOneFile
53
- // itself catches LLM errors and returns a ScanWorkResult with `error` set,
54
- // so the `{error}` branch only fires for truly unexpected throws.
74
+ // scanOneFile catches LLM errors and returns ScanWorkResult with `error` set,
75
+ // so a !r.ok slot here only fires for truly unexpected throws.
55
76
  return settled.map((r, i) => {
56
- if (r && typeof r === "object" && "error" in r && !("buffer" in r)) {
57
- return {
58
- decryptedFile: files[i],
59
- buffer: new BufferedWriteContext(files[i].fileName),
60
- error: String(r.error),
61
- agentText: "",
62
- };
63
- }
64
- return r;
77
+ if (r.ok)
78
+ return r.value;
79
+ return {
80
+ decryptedFile: files[i],
81
+ buffer: new BufferedWriteContext(files[i].fileName),
82
+ error: String(r.error),
83
+ agentText: "",
84
+ };
65
85
  });
66
86
  }
67
87
  async function scanOneFile(db, file, events) {
@@ -99,7 +119,7 @@ async function scanOneFile(db, file, events) {
99
119
  fileName: file.fileName,
100
120
  status: "scanned",
101
121
  transactions: buffer.transactions.length,
102
- concerns: buffer.concerns.length,
122
+ unknowns: buffer.unknowns.length,
103
123
  });
104
124
  return { decryptedFile: file, buffer, agentText: text };
105
125
  }
@@ -109,103 +129,43 @@ async function scanOneFile(db, file, events) {
109
129
  fileName: file.fileName,
110
130
  status: "failed",
111
131
  transactions: 0,
112
- concerns: 0,
132
+ unknowns: 0,
113
133
  error: message,
114
134
  });
115
135
  return { decryptedFile: file, buffer, error: message, agentText: "" };
116
136
  }
117
137
  }
118
- /** Phase 3: cross-file correlation */
119
- /**
120
- * For every pair of buffered entries that look like the same money movement
121
- * across two different files, append a mirror concern to each side's buffer.
122
- * Returns the number of pairs detected so the CLI can report it.
123
- */
124
- function applyCrossFileCorrelations(results) {
125
- const all = [];
126
- for (const res of results) {
127
- if (res.error)
128
- continue;
129
- for (const bt of res.buffer.transactions) {
130
- all.push({
131
- file: res,
132
- transactionId: bt.transaction_id,
133
- postings: bt.input.postings,
134
- date: bt.input.date,
135
- description: bt.input.description,
136
- });
137
- }
138
- }
139
- const candidates = all.map(e => {
140
- const debit = e.postings.reduce((s, p) => s + (p.debit ?? 0), 0);
141
- const currency = e.postings.find(p => p.currency)?.currency ?? "THB";
142
- const ids = Array.from(new Set(e.postings.map(p => p.account_id)));
143
- return {
144
- id: e.transactionId,
145
- date: e.date,
146
- description: e.description,
147
- amount: Math.round(debit * 100) / 100,
148
- currency,
149
- account_ids: ids,
150
- account_names: ids,
151
- };
152
- });
153
- const pairs = correlatePairs(candidates, { toleranceDays: 3 });
154
- const byTransaction = new Map(all.map(a => [a.transactionId, a]));
155
- for (const pair of pairs) {
156
- const a = byTransaction.get(pair.a.id);
157
- const b = byTransaction.get(pair.b.id);
158
- if (!a || !b)
159
- continue;
160
- if (a.file === b.file)
161
- continue;
162
- const amountStr = `฿${pair.amount.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
163
- a.file.buffer.appendConcern({
164
- transaction_id: a.transactionId,
165
- account_id: null,
166
- prompt: `Looks like the matching half of this ${amountStr} movement on ${a.date} was also recorded in ${b.file.decryptedFile.fileName} on ${b.date}. Merge during review?`,
167
- options: ["Yes — merge into one transaction", "No — these are two real events", "Skip — leave as is"],
168
- });
169
- b.file.buffer.appendConcern({
170
- transaction_id: b.transactionId,
171
- account_id: null,
172
- prompt: `Looks like the matching half of this ${amountStr} movement on ${b.date} was also recorded in ${a.file.decryptedFile.fileName} on ${a.date}. Merge during review?`,
173
- options: ["Yes — merge into one transaction", "No — these are two real events", "Skip — leave as is"],
174
- });
175
- }
176
- return pairs.filter(p => byTransaction.get(p.a.id)?.file !== byTransaction.get(p.b.id)?.file).length;
177
- }
178
- /** Phase 4: commit */
179
138
  function commitAll(db, decryptResult, scanResults) {
180
- const out = [];
139
+ const details = [];
140
+ const committedFileIds = [];
181
141
  for (const skipped of decryptResult.skipped) {
182
- out.push({
142
+ details.push({
183
143
  name: skipped.file.name,
184
144
  relPath: skipped.file.relPath,
185
145
  status: "skipped",
186
146
  transactions: 0,
187
- concerns: countOpenConcerns(db, { file_id: skipped.existingScannedFileId }),
147
+ unknowns: countOpenUnknowns(db, { file_id: skipped.existingScannedFileId }),
188
148
  });
189
149
  }
190
150
  for (const failed of decryptResult.failed) {
191
- out.push({
151
+ details.push({
192
152
  name: failed.file.name,
193
153
  relPath: failed.file.relPath,
194
154
  status: "failed",
195
155
  transactions: 0,
196
- concerns: 0,
156
+ unknowns: 0,
197
157
  error: failed.error,
198
158
  });
199
159
  }
200
160
  for (const res of scanResults) {
201
161
  const { decryptedFile, buffer, error, agentText } = res;
202
162
  if (error) {
203
- out.push({
163
+ details.push({
204
164
  name: decryptedFile.fileName,
205
165
  relPath: decryptedFile.relPath,
206
166
  status: "failed",
207
167
  transactions: 0,
208
- concerns: buffer.concerns.length,
168
+ unknowns: buffer.unknowns.length,
209
169
  error,
210
170
  });
211
171
  continue;
@@ -221,54 +181,55 @@ function commitAll(db, decryptResult, scanResults) {
221
181
  });
222
182
  const counts = buffer.commit(db, scannedFileId);
223
183
  setFileStatus(db, scannedFileId, "scanned", { raw_text: agentText });
224
- out.push({
184
+ committedFileIds.push(scannedFileId);
185
+ details.push({
225
186
  name: decryptedFile.fileName,
226
187
  relPath: decryptedFile.relPath,
227
188
  status: decryptedFile.replacesPriorScannedFileId ? "replaced" : "scanned",
228
189
  transactions: counts.transactions,
229
- concerns: counts.concerns,
190
+ unknowns: counts.unknowns,
230
191
  });
231
192
  }
232
193
  catch (err) {
233
- out.push({
194
+ details.push({
234
195
  name: decryptedFile.fileName,
235
196
  relPath: decryptedFile.relPath,
236
197
  status: "failed",
237
198
  transactions: 0,
238
- concerns: buffer.concerns.length,
199
+ unknowns: buffer.unknowns.length,
239
200
  error: err?.message ?? "commit failed",
240
201
  });
241
202
  }
242
203
  }
243
- return out;
204
+ return { details, committedFileIds };
244
205
  }
245
206
  /** Summary assembly */
246
- function buildSummary(total, details, _decrypt) {
207
+ function buildSummary(total, details) {
247
208
  const summary = {
248
209
  total,
249
210
  scanned: 0,
250
211
  replaced: 0,
251
212
  skipped: 0,
252
213
  failed: 0,
253
- concerns: 0,
214
+ unknowns: 0,
254
215
  details,
255
216
  };
256
217
  for (const d of details) {
257
218
  summary[d.status]++;
258
- summary.concerns += d.concerns;
219
+ summary.unknowns += d.unknowns;
259
220
  }
260
221
  return summary;
261
222
  }
262
223
  function buildAbortedSummary(total, decrypt) {
263
224
  const details = [
264
225
  ...decrypt.skipped.map(s => ({
265
- name: s.file.name, relPath: s.file.relPath, status: "skipped", transactions: 0, concerns: 0,
226
+ name: s.file.name, relPath: s.file.relPath, status: "skipped", transactions: 0, unknowns: 0,
266
227
  })),
267
228
  ...decrypt.failed.map(f => ({
268
- name: f.file.name, relPath: f.file.relPath, status: "failed", transactions: 0, concerns: 0, error: f.error,
229
+ name: f.file.name, relPath: f.file.relPath, status: "failed", transactions: 0, unknowns: 0, error: f.error,
269
230
  })),
270
231
  ];
271
- return buildSummary(total, details, decrypt);
232
+ return buildSummary(total, details);
272
233
  }
273
234
  /** Low-level DB helpers */
274
235
  function deleteScannedFile(db, id) {
@@ -13,8 +13,8 @@ export function buildScanUserMessage(opts) {
13
13
  `2. Infer the primary account type (asset / liability / income / expense) from the document's header, account type field, and transaction patterns.`,
14
14
  `3. If this document references an account that isn't yet in the chart, call create_account once (pass parent_id under the matching top-level type root). Mask the account number to the last 4 digits.`,
15
15
  `4. Persist any document-level metadata you find (statement_day, due_day, points_balance, etc.) using update_account_metadata.`,
16
- `5. For every transaction in the document, call record_transaction with balanced debit/credit postings. Attach a merchant block (canonical_name + alias + default_account_id when categorization is confident) for any external counter-party. Reuse existing accounts; create expense categories under their parent (e.g. expense:food before expense:food:groceries) as needed. When you cannot categorize confidently, post the expense side to expense:uncategorized and call note_concern with kind="uncategorized_expense".`,
17
- `6. Never pause to ask the user. If a row is ambiguous, post your best-guess transaction first, then call note_concern with details and the new transaction_id. If a row is truly unparseable, skip it and call note_concern with the raw row text (no transaction_id). A missing row is better than a wrong row.`,
16
+ `5. For every transaction in the document, call record_transaction with balanced debit/credit postings. Attach a merchant block (canonical_name + alias + default_account_id when categorization is confident) for any external counter-party. Reuse existing accounts; create expense categories under their parent (e.g. expense:food before expense:food:groceries) as needed. When you cannot categorize confidently, post the expense side to expense:uncategorized and call note_unknown with kind="uncategorized_expense".`,
17
+ `6. Never pause to ask the user. If a row is ambiguous, post your best-guess transaction first, then call note_unknown with details and the new transaction_id. If a row is truly unparseable, skip it and call note_unknown with the raw row text (no transaction_id). A missing row is better than a wrong row.`,
18
18
  `7. When you are done, call mark_file_scanned with a short summary.`,
19
19
  ].join("\n");
20
20
  }
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "plasalid",
3
- "version": "0.5.7",
3
+ "version": "0.6.0",
4
4
  "description": "Plasalid — AI Harness for Personal Finance",
5
5
  "keywords": [
6
6
  "finance",
7
+ "harness",
7
8
  "personal-finance",
8
9
  "aggregator",
9
10
  "parser",