plasalid 0.3.4 → 0.4.1

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 (54) hide show
  1. package/README.md +29 -40
  2. package/dist/accounts/taxonomy.d.ts +1 -1
  3. package/dist/accounts/taxonomy.js +2 -2
  4. package/dist/ai/agent.d.ts +6 -5
  5. package/dist/ai/agent.js +7 -6
  6. package/dist/ai/memory.d.ts +12 -5
  7. package/dist/ai/memory.js +12 -0
  8. package/dist/ai/personas.d.ts +10 -0
  9. package/dist/ai/personas.js +123 -0
  10. package/dist/ai/prompt-sections.d.ts +44 -0
  11. package/dist/ai/prompt-sections.js +89 -0
  12. package/dist/ai/system-prompt.d.ts +3 -3
  13. package/dist/ai/system-prompt.js +44 -165
  14. package/dist/ai/tools/index.js +12 -7
  15. package/dist/ai/tools/ingest.d.ts +2 -1
  16. package/dist/ai/tools/ingest.js +220 -83
  17. package/dist/ai/tools/read.js +31 -0
  18. package/dist/ai/tools/review.d.ts +2 -0
  19. package/dist/ai/tools/review.js +362 -0
  20. package/dist/ai/tools/scan.js +4 -2
  21. package/dist/ai/tools/types.d.ts +23 -3
  22. package/dist/cli/commands/review.d.ts +2 -0
  23. package/dist/cli/commands/review.js +15 -0
  24. package/dist/cli/commands/scan.d.ts +4 -2
  25. package/dist/cli/commands/scan.js +147 -19
  26. package/dist/cli/index.js +11 -8
  27. package/dist/cli/ink/scan_dashboard.d.ts +38 -0
  28. package/dist/cli/ink/scan_dashboard.js +62 -0
  29. package/dist/cli/ux.d.ts +2 -1
  30. package/dist/cli/ux.js +36 -2
  31. package/dist/db/queries/account_balance.d.ts +1 -0
  32. package/dist/db/queries/concerns.d.ts +47 -0
  33. package/dist/db/queries/concerns.js +87 -0
  34. package/dist/db/queries/journal.d.ts +74 -8
  35. package/dist/db/queries/journal.js +131 -19
  36. package/dist/db/queries/recurrences.d.ts +33 -0
  37. package/dist/db/queries/recurrences.js +130 -0
  38. package/dist/db/schema.js +25 -2
  39. package/dist/reviewer/pipeline.d.ts +18 -0
  40. package/dist/reviewer/pipeline.js +46 -0
  41. package/dist/reviewer/prompts.d.ts +12 -0
  42. package/dist/reviewer/prompts.js +22 -0
  43. package/dist/scanner/account_mutex.d.ts +1 -0
  44. package/dist/scanner/account_mutex.js +16 -0
  45. package/dist/scanner/buffer.d.ts +48 -0
  46. package/dist/scanner/buffer.js +63 -0
  47. package/dist/scanner/concurrency.d.ts +14 -0
  48. package/dist/scanner/concurrency.js +31 -0
  49. package/dist/scanner/decrypt_queue.d.ts +57 -0
  50. package/dist/scanner/decrypt_queue.js +96 -0
  51. package/dist/scanner/pipeline.d.ts +46 -18
  52. package/dist/scanner/pipeline.js +250 -97
  53. package/dist/scanner/prompts.js +1 -1
  54. package/package.json +1 -1
@@ -6,6 +6,17 @@ const TOLERANCE = 0.005;
6
6
  * a header, header never lands without lines.
7
7
  */
8
8
  export function recordJournalEntry(db, entry) {
9
+ const validated = validateJournalEntry(entry);
10
+ const tx = db.transaction(() => { insertJournalEntryRows(db, validated); });
11
+ tx();
12
+ return validated.id;
13
+ }
14
+ /**
15
+ * Validate balance + invariants and assign an id. Pure (no DB writes). Used by
16
+ * both `recordJournalEntry` and the buffered-scan commit path; the latter
17
+ * already runs inside its own transaction and must not open another.
18
+ */
19
+ export function validateJournalEntry(entry) {
9
20
  if (!entry.lines || entry.lines.length < 2) {
10
21
  throw new Error("Journal entry must contain at least two lines.");
11
22
  }
@@ -29,19 +40,21 @@ export function recordJournalEntry(db, entry) {
29
40
  if (Math.abs(debitTotal - creditTotal) > TOLERANCE) {
30
41
  throw new Error(`Journal entry does not balance: debits ${debitTotal.toFixed(2)} vs credits ${creditTotal.toFixed(2)}.`);
31
42
  }
32
- const entryId = `je:${randomUUID()}`;
33
- const insertHeader = db.prepare(`INSERT INTO journal_entries (id, date, description, source_file_id, source_page)
34
- VALUES (?, ?, ?, ?, ?)`);
43
+ return { ...entry, id: entry.id ?? `je:${randomUUID()}` };
44
+ }
45
+ /**
46
+ * Insert-only counterpart to `recordJournalEntry`. The caller is responsible
47
+ * for opening a transaction (or for accepting partial writes). Expects an
48
+ * already-validated entry from `validateJournalEntry`.
49
+ */
50
+ export function insertJournalEntryRows(db, entry) {
51
+ db.prepare(`INSERT INTO journal_entries (id, date, description, source_file_id, source_page)
52
+ VALUES (?, ?, ?, ?, ?)`).run(entry.id, entry.date, entry.description, entry.source_file_id ?? null, entry.source_page ?? null);
35
53
  const insertLine = db.prepare(`INSERT INTO journal_lines (id, entry_id, account_id, debit, credit, currency, memo, pii_flag)
36
54
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
37
- const tx = db.transaction(() => {
38
- insertHeader.run(entryId, entry.date, entry.description, entry.source_file_id ?? null, entry.source_page ?? null);
39
- for (const line of entry.lines) {
40
- insertLine.run(`jl:${randomUUID()}`, entryId, line.account_id, line.debit ?? 0, line.credit ?? 0, line.currency || "THB", line.memo ?? null, line.pii_flag ? 1 : 0);
41
- }
42
- });
43
- tx();
44
- return entryId;
55
+ for (const line of entry.lines) {
56
+ insertLine.run(`jl:${randomUUID()}`, entry.id, line.account_id, line.debit ?? 0, line.credit ?? 0, line.currency || "THB", line.memo ?? null, line.pii_flag ? 1 : 0);
57
+ }
45
58
  }
46
59
  export function updateJournalEntry(db, entryId, fields) {
47
60
  const sets = [];
@@ -104,13 +117,7 @@ export function findDuplicateEntries(db, opts = {}) {
104
117
  ? `WHERE je.id IN (SELECT entry_id FROM journal_lines WHERE account_id = ?)`
105
118
  : ``;
106
119
  const params = opts.accountId ? [opts.accountId] : [];
107
- // Small chart of accounts → a single name lookup beats GROUP_CONCAT join
108
- // hacks (account names can contain commas which break a comma-separated
109
- // concat, and SQLite's GROUP_CONCAT has no robust escape mechanism).
110
- const nameById = new Map();
111
- for (const row of db.prepare(`SELECT id, name FROM accounts`).all()) {
112
- nameById.set(row.id, row.name);
113
- }
120
+ const nameById = loadAccountNames(db);
114
121
  const rows = db.prepare(`SELECT je.id, je.date, je.description,
115
122
  COALESCE(SUM(jl.debit), 0) AS amount,
116
123
  GROUP_CONCAT(jl.account_id) AS account_ids
@@ -164,13 +171,118 @@ export function findDuplicateEntries(db, opts = {}) {
164
171
  }
165
172
  return groups;
166
173
  }
167
- function dayDiff(a, b) {
174
+ export function dayDiff(a, b) {
168
175
  const aDate = Date.parse(a);
169
176
  const bDate = Date.parse(b);
170
177
  if (Number.isNaN(aDate) || Number.isNaN(bDate))
171
178
  return Number.POSITIVE_INFINITY;
172
179
  return Math.abs(Math.round((bDate - aDate) / 86_400_000));
173
180
  }
181
+ /**
182
+ * Load all account id → name pairs into an in-memory map. Cheap on the small
183
+ * charts of accounts Plasalid deals with, and avoids GROUP_CONCAT join hacks
184
+ * (account names can contain commas which break a comma-separated concat, and
185
+ * SQLite's GROUP_CONCAT has no robust escape mechanism).
186
+ */
187
+ function loadAccountNames(db) {
188
+ const map = new Map();
189
+ for (const row of db.prepare(`SELECT id, name FROM accounts`).all()) {
190
+ map.set(row.id, row.name);
191
+ }
192
+ return map;
193
+ }
194
+ /**
195
+ * Heuristic: surface pairs of entries that look like the same money movement
196
+ * recorded against different accounts (e.g. a bank-to-card transfer that lands
197
+ * once on the bank statement and again on the card statement). Filters out
198
+ * pairs whose account-id sets overlap (those are duplicates, not correlations).
199
+ */
200
+ export function findCorrelatedEntries(db, opts = {}) {
201
+ const toleranceDays = Math.max(0, Math.floor(opts.toleranceDays ?? 3));
202
+ const minAmount = opts.minAmount ?? 0;
203
+ const dateFilter = [];
204
+ const params = [];
205
+ if (opts.from) {
206
+ dateFilter.push("je.date >= ?");
207
+ params.push(opts.from);
208
+ }
209
+ if (opts.to) {
210
+ dateFilter.push("je.date <= ?");
211
+ params.push(opts.to);
212
+ }
213
+ const where = dateFilter.length ? `WHERE ${dateFilter.join(" AND ")}` : "";
214
+ const nameById = loadAccountNames(db);
215
+ const rows = db.prepare(`SELECT je.id, je.date, je.description,
216
+ COALESCE(SUM(jl.debit), 0) AS amount,
217
+ COALESCE(MAX(jl.currency), 'THB') AS currency,
218
+ GROUP_CONCAT(jl.account_id) AS account_ids
219
+ FROM journal_entries je
220
+ LEFT JOIN journal_lines jl ON jl.entry_id = je.id
221
+ ${where}
222
+ GROUP BY je.id`).all(...params);
223
+ const entries = rows
224
+ .filter(r => r.amount >= minAmount)
225
+ .map(r => {
226
+ const ids = (r.account_ids ?? "").split(",").filter(Boolean);
227
+ return {
228
+ id: r.id,
229
+ date: r.date,
230
+ description: r.description,
231
+ amount: Math.round(r.amount * 100) / 100,
232
+ currency: r.currency || "THB",
233
+ account_ids: ids,
234
+ account_names: ids.map(id => nameById.get(id) ?? id),
235
+ };
236
+ });
237
+ return correlatePairs(entries, { toleranceDays });
238
+ }
239
+ /**
240
+ * Pure pair-finder: given an array of candidates already filtered by amount
241
+ * and equipped with account_ids/names, return the cross-pairs that look like
242
+ * the same money movement on different accounts (date within toleranceDays,
243
+ * same amount + currency, non-overlapping account sets).
244
+ *
245
+ * Used by the DB-backed `findCorrelatedEntries` and by the scan-time
246
+ * coordinator that runs over buffered, not-yet-committed entries.
247
+ */
248
+ export function correlatePairs(entries, opts = {}) {
249
+ const toleranceDays = Math.max(0, Math.floor(opts.toleranceDays ?? 3));
250
+ // Bucket by (amount-cents, currency) so we only compare entries that could
251
+ // plausibly pair. O(n) bucketing + O(k²) per bucket dominates only when many
252
+ // entries share the same amount.
253
+ const buckets = new Map();
254
+ for (const e of entries) {
255
+ const key = `${Math.round(e.amount * 100)}|${e.currency}`;
256
+ const arr = buckets.get(key) ?? [];
257
+ arr.push(e);
258
+ buckets.set(key, arr);
259
+ }
260
+ const pairs = [];
261
+ for (const bucket of buckets.values()) {
262
+ if (bucket.length < 2)
263
+ continue;
264
+ bucket.sort((x, y) => x.date.localeCompare(y.date));
265
+ for (let i = 0; i < bucket.length; i++) {
266
+ for (let j = i + 1; j < bucket.length; j++) {
267
+ const a = bucket[i], b = bucket[j];
268
+ const gap = dayDiff(a.date, b.date);
269
+ if (gap > toleranceDays)
270
+ break; // bucket is sorted by date
271
+ const overlap = a.account_ids.some(id => b.account_ids.includes(id));
272
+ if (overlap)
273
+ continue;
274
+ pairs.push({
275
+ amount: a.amount,
276
+ currency: a.currency,
277
+ day_gap: gap,
278
+ a: { id: a.id, date: a.date, description: a.description, account_ids: a.account_ids, account_names: a.account_names },
279
+ b: { id: b.id, date: b.date, description: b.description, account_ids: b.account_ids, account_names: b.account_names },
280
+ });
281
+ }
282
+ }
283
+ }
284
+ return pairs;
285
+ }
174
286
  export function listJournalLines(db, opts = {}) {
175
287
  const conditions = [];
176
288
  const params = [];
@@ -0,0 +1,33 @@
1
+ import type Database from "libsql";
2
+ export type RecurrenceFrequency = "weekly" | "biweekly" | "monthly" | "annually";
3
+ export interface RecurrenceCandidate {
4
+ account_id: string;
5
+ account_name: string;
6
+ amount: number;
7
+ currency: string;
8
+ side: "debit" | "credit";
9
+ entries: {
10
+ id: string;
11
+ date: string;
12
+ description: string;
13
+ }[];
14
+ median_days_between: number;
15
+ implied_frequency: RecurrenceFrequency | "irregular";
16
+ }
17
+ export interface FindRecurrencesOptions {
18
+ accountId?: string;
19
+ /** Minimum sightings to qualify. Default 3. */
20
+ minOccurrences?: number;
21
+ }
22
+ export declare function findRecurrenceCandidates(db: Database.Database, opts?: FindRecurrencesOptions): RecurrenceCandidate[];
23
+ export interface RecordRecurrenceInput {
24
+ account_id: string;
25
+ description: string;
26
+ frequency: RecurrenceFrequency;
27
+ amount_typical?: number | null;
28
+ currency?: string;
29
+ entry_ids: string[];
30
+ notes?: string | null;
31
+ }
32
+ export declare function recordRecurrence(db: Database.Database, input: RecordRecurrenceInput): string;
33
+ export declare function linkEntryToRecurrence(db: Database.Database, entryId: string, recurrenceId: string): void;
@@ -0,0 +1,130 @@
1
+ import { randomUUID } from "crypto";
2
+ import { dayDiff } from "./journal.js";
3
+ export function findRecurrenceCandidates(db, opts = {}) {
4
+ const minOccurrences = Math.max(2, opts.minOccurrences ?? 3);
5
+ const accountFilter = opts.accountId ? `AND jl.account_id = ?` : ``;
6
+ const params = opts.accountId ? [opts.accountId] : [];
7
+ const rows = db.prepare(`SELECT jl.account_id,
8
+ a.name AS account_name,
9
+ jl.currency,
10
+ CASE WHEN jl.debit > 0 THEN jl.debit ELSE jl.credit END AS amount,
11
+ CASE WHEN jl.debit > 0 THEN 'debit' ELSE 'credit' END AS side,
12
+ je.id AS entry_id,
13
+ je.date,
14
+ je.description
15
+ FROM journal_lines jl
16
+ JOIN journal_entries je ON je.id = jl.entry_id
17
+ JOIN accounts a ON a.id = jl.account_id
18
+ WHERE je.recurrence_id IS NULL
19
+ AND (jl.debit > 0 OR jl.credit > 0)
20
+ ${accountFilter}
21
+ ORDER BY jl.account_id, jl.currency, amount, je.date`).all(...params);
22
+ const buckets = new Map();
23
+ for (const r of rows) {
24
+ const key = `${r.account_id}|${r.currency}|${Math.round(r.amount * 100)}|${r.side}`;
25
+ const arr = buckets.get(key) ?? [];
26
+ arr.push(r);
27
+ buckets.set(key, arr);
28
+ }
29
+ const candidates = [];
30
+ for (const bucket of buckets.values()) {
31
+ if (bucket.length < minOccurrences)
32
+ continue;
33
+ const dates = bucket.map(r => r.date);
34
+ const diffs = [];
35
+ for (let i = 1; i < dates.length; i++) {
36
+ diffs.push(dayDiff(dates[i - 1], dates[i]));
37
+ }
38
+ const median = medianOf(diffs);
39
+ candidates.push({
40
+ account_id: bucket[0].account_id,
41
+ account_name: bucket[0].account_name,
42
+ amount: Math.round(bucket[0].amount * 100) / 100,
43
+ currency: bucket[0].currency,
44
+ side: bucket[0].side,
45
+ entries: bucket.map(r => ({ id: r.entry_id, date: r.date, description: r.description })),
46
+ median_days_between: median,
47
+ implied_frequency: classifyFrequency(median),
48
+ });
49
+ }
50
+ return candidates;
51
+ }
52
+ function medianOf(values) {
53
+ if (values.length === 0)
54
+ return 0;
55
+ const sorted = [...values].sort((a, b) => a - b);
56
+ const mid = Math.floor(sorted.length / 2);
57
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
58
+ }
59
+ function classifyFrequency(medianDays) {
60
+ if (medianDays >= 6 && medianDays <= 8)
61
+ return "weekly";
62
+ if (medianDays >= 13 && medianDays <= 15)
63
+ return "biweekly";
64
+ if (medianDays >= 27 && medianDays <= 32)
65
+ return "monthly";
66
+ if (medianDays >= 360 && medianDays <= 370)
67
+ return "annually";
68
+ return "irregular";
69
+ }
70
+ const FREQ_PERIOD_DAYS = {
71
+ weekly: 7,
72
+ biweekly: 14,
73
+ monthly: 30,
74
+ annually: 365,
75
+ };
76
+ export function recordRecurrence(db, input) {
77
+ if (!input.entry_ids || input.entry_ids.length === 0) {
78
+ throw new Error("recordRecurrence requires at least one entry_id.");
79
+ }
80
+ const placeholders = input.entry_ids.map(() => "?").join(",");
81
+ const dateRows = db.prepare(`SELECT date FROM journal_entries WHERE id IN (${placeholders}) ORDER BY date ASC`).all(...input.entry_ids);
82
+ if (dateRows.length === 0) {
83
+ throw new Error("None of the supplied entry_ids exist.");
84
+ }
85
+ const firstSeen = dateRows[0].date;
86
+ const lastSeen = dateRows[dateRows.length - 1].date;
87
+ const nextExpected = addDays(lastSeen, FREQ_PERIOD_DAYS[input.frequency]);
88
+ const id = `rc:${randomUUID()}`;
89
+ const tx = db.transaction(() => {
90
+ db.prepare(`INSERT INTO recurrences
91
+ (id, account_id, description, frequency, amount_typical, currency,
92
+ first_seen_date, last_seen_date, next_expected_date, notes)
93
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, input.account_id, input.description, input.frequency, input.amount_typical ?? null, input.currency || "THB", firstSeen, lastSeen, nextExpected, input.notes ?? null);
94
+ const updateEntry = db.prepare(`UPDATE journal_entries SET recurrence_id = ? WHERE id = ?`);
95
+ for (const entryId of input.entry_ids)
96
+ updateEntry.run(id, entryId);
97
+ });
98
+ tx();
99
+ return id;
100
+ }
101
+ export function linkEntryToRecurrence(db, entryId, recurrenceId) {
102
+ const recurrence = db
103
+ .prepare(`SELECT frequency FROM recurrences WHERE id = ?`)
104
+ .get(recurrenceId);
105
+ if (!recurrence)
106
+ throw new Error(`Recurrence ${recurrenceId} not found.`);
107
+ const entry = db
108
+ .prepare(`SELECT date FROM journal_entries WHERE id = ?`)
109
+ .get(entryId);
110
+ if (!entry)
111
+ throw new Error(`Entry ${entryId} not found.`);
112
+ const tx = db.transaction(() => {
113
+ db.prepare(`UPDATE journal_entries SET recurrence_id = ? WHERE id = ?`).run(recurrenceId, entryId);
114
+ // Recompute first/last/next from the full member set so the recurrence
115
+ // metadata stays in sync after every attach.
116
+ const span = db
117
+ .prepare(`SELECT MIN(date) AS first, MAX(date) AS last FROM journal_entries WHERE recurrence_id = ?`)
118
+ .get(recurrenceId);
119
+ const nextExpected = addDays(span.last, FREQ_PERIOD_DAYS[recurrence.frequency]);
120
+ db.prepare(`UPDATE recurrences SET first_seen_date = ?, last_seen_date = ?, next_expected_date = ? WHERE id = ?`).run(span.first, span.last, nextExpected, recurrenceId);
121
+ });
122
+ tx();
123
+ }
124
+ function addDays(dateIso, days) {
125
+ const t = Date.parse(dateIso);
126
+ if (Number.isNaN(t))
127
+ return dateIso;
128
+ const next = new Date(t + days * 86_400_000);
129
+ return next.toISOString().slice(0, 10);
130
+ }
package/dist/db/schema.js CHANGED
@@ -13,6 +13,7 @@ export function migrate(db) {
13
13
  points_balance REAL,
14
14
  metadata_json TEXT,
15
15
  pii_flag INTEGER NOT NULL DEFAULT 0,
16
+ has_concern INTEGER NOT NULL DEFAULT 0,
16
17
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
17
18
  );
18
19
 
@@ -21,22 +22,42 @@ export function migrate(db) {
21
22
  path TEXT NOT NULL,
22
23
  file_hash TEXT NOT NULL UNIQUE,
23
24
  mime TEXT NOT NULL,
24
- status TEXT NOT NULL CHECK(status IN ('pending','scanned','needs_input','failed')),
25
+ status TEXT NOT NULL CHECK(status IN ('pending','scanned','failed')),
25
26
  raw_text TEXT,
26
27
  scanned_at TEXT,
27
28
  error TEXT,
28
29
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
29
30
  );
30
31
 
32
+ CREATE TABLE IF NOT EXISTS recurrences (
33
+ id TEXT PRIMARY KEY,
34
+ account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
35
+ description TEXT NOT NULL,
36
+ frequency TEXT NOT NULL CHECK(frequency IN ('weekly','biweekly','monthly','annually')),
37
+ amount_typical REAL,
38
+ currency TEXT NOT NULL DEFAULT 'THB',
39
+ first_seen_date TEXT,
40
+ last_seen_date TEXT,
41
+ next_expected_date TEXT,
42
+ notes TEXT,
43
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
44
+ );
45
+
46
+ CREATE INDEX IF NOT EXISTS recurrences_account_idx ON recurrences(account_id);
47
+
31
48
  CREATE TABLE IF NOT EXISTS journal_entries (
32
49
  id TEXT PRIMARY KEY,
33
50
  date TEXT NOT NULL,
34
51
  description TEXT NOT NULL,
35
52
  source_file_id TEXT REFERENCES scanned_files(id) ON DELETE CASCADE,
36
53
  source_page INTEGER,
54
+ recurrence_id TEXT REFERENCES recurrences(id) ON DELETE SET NULL,
55
+ has_concern INTEGER NOT NULL DEFAULT 0,
37
56
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
38
57
  );
39
58
 
59
+ CREATE INDEX IF NOT EXISTS journal_entries_recurrence_idx ON journal_entries(recurrence_id);
60
+
40
61
  CREATE TABLE IF NOT EXISTS journal_lines (
41
62
  id TEXT PRIMARY KEY,
42
63
  entry_id TEXT NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
@@ -54,9 +75,11 @@ export function migrate(db) {
54
75
  CREATE INDEX IF NOT EXISTS journal_entries_source_file_idx ON journal_entries(source_file_id);
55
76
  CREATE INDEX IF NOT EXISTS journal_entries_date_idx ON journal_entries(date);
56
77
 
57
- CREATE TABLE IF NOT EXISTS pending_questions (
78
+ CREATE TABLE IF NOT EXISTS concerns (
58
79
  id TEXT PRIMARY KEY,
59
80
  file_id TEXT REFERENCES scanned_files(id) ON DELETE CASCADE,
81
+ entry_id TEXT REFERENCES journal_entries(id) ON DELETE CASCADE,
82
+ account_id TEXT REFERENCES accounts(id) ON DELETE CASCADE,
60
83
  prompt TEXT NOT NULL,
61
84
  options_json TEXT,
62
85
  answer TEXT,
@@ -0,0 +1,18 @@
1
+ export interface ReviewOptions {
2
+ accountId?: string;
3
+ from?: string;
4
+ to?: string;
5
+ dryRun?: boolean;
6
+ interactive?: boolean;
7
+ }
8
+ export interface ReviewSummary {
9
+ summary: string;
10
+ dryRun: boolean;
11
+ }
12
+ /**
13
+ * Walk the existing journal with the review-profile agent: surface open
14
+ * concerns, detect correlated transactions and recurrences, propose fixes,
15
+ * apply them (or print "would do X" stubs when dryRun is on) after the user
16
+ * confirms one step at a time.
17
+ */
18
+ export declare function runReview(opts?: ReviewOptions): Promise<ReviewSummary>;
@@ -0,0 +1,46 @@
1
+ import { getDb } from "../db/connection.js";
2
+ import { runReviewAgent } from "../ai/agent.js";
3
+ import { statusSpinner, makePromptUser, makeAgentOnProgress, } from "../cli/ux.js";
4
+ import { buildReviewUserMessage } from "./prompts.js";
5
+ /**
6
+ * Walk the existing journal with the review-profile agent: surface open
7
+ * concerns, detect correlated transactions and recurrences, propose fixes,
8
+ * apply them (or print "would do X" stubs when dryRun is on) after the user
9
+ * confirms one step at a time.
10
+ */
11
+ export async function runReview(opts = {}) {
12
+ const db = getDb();
13
+ const interactive = opts.interactive ?? true;
14
+ const dryRun = !!opts.dryRun;
15
+ const scope = {
16
+ accountId: opts.accountId,
17
+ from: opts.from,
18
+ to: opts.to,
19
+ dryRun,
20
+ };
21
+ const spinner = statusSpinner(`Reviewing${dryRun ? " (dry-run)" : ""}...`);
22
+ const promptUser = interactive ? makePromptUser(spinner) : undefined;
23
+ let summary = "";
24
+ try {
25
+ await runReviewAgent({
26
+ db,
27
+ prompt: scope,
28
+ initialMessages: [
29
+ { role: "user", content: buildReviewUserMessage(scope) },
30
+ ],
31
+ agentCtx: {
32
+ interactive,
33
+ dryRun,
34
+ promptUser,
35
+ onComplete: (s) => { summary = s; },
36
+ },
37
+ onProgress: makeAgentOnProgress(spinner),
38
+ });
39
+ spinner.succeed(dryRun ? "Review complete (dry-run — no writes)." : "Review complete.");
40
+ }
41
+ catch (err) {
42
+ spinner.fail(`Review failed: ${err.message}`);
43
+ throw err;
44
+ }
45
+ return { summary, dryRun };
46
+ }
@@ -0,0 +1,12 @@
1
+ export interface ReviewScope {
2
+ accountId?: string;
3
+ from?: string;
4
+ to?: string;
5
+ dryRun: boolean;
6
+ }
7
+ /**
8
+ * Kickoff message the review agent receives. The persona + chart-of-accounts
9
+ * snapshot live in the system prompt (`buildReviewSystemPrompt`); this is
10
+ * the per-session instruction.
11
+ */
12
+ export declare function buildReviewUserMessage(scope: ReviewScope): string;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Kickoff message the review agent receives. The persona + chart-of-accounts
3
+ * snapshot live in the system prompt (`buildReviewSystemPrompt`); this is
4
+ * the per-session instruction.
5
+ */
6
+ export function buildReviewUserMessage(scope) {
7
+ return [
8
+ `Review the local Plasalid journal.`,
9
+ ``,
10
+ `Scope:`,
11
+ `- account: ${scope.accountId ?? "all"}`,
12
+ `- from: ${scope.from ?? "all time"}`,
13
+ `- to: ${scope.to ?? "now"}`,
14
+ `- dry run: ${scope.dryRun ? "yes — write tools are no-ops" : "no — writes commit after confirmation"}`,
15
+ ``,
16
+ `Steps:`,
17
+ `1. Survey first: list_accounts, get_net_worth, count open concerns, then find_duplicate_entries, find_similar_accounts, find_unused_accounts, find_correlated_entries, find_recurrences. Hold the candidate list internally.`,
18
+ `2. Prioritize open concerns, then correlated transactions, then recurrences, then chart-of-accounts hygiene.`,
19
+ `3. Ask one focused question at a time via ask_user. After each answer, apply the change and re-survey only if the change invalidated other candidates.`,
20
+ `4. Loop until no open concerns remain (or the user keeps choosing "Skip — leave as is"). Then call mark_review_done with a short summary of what was applied, recorded, and skipped.`,
21
+ ].join("\n");
22
+ }
@@ -0,0 +1 @@
1
+ export declare function runExclusive<T>(fn: () => Promise<T> | T): Promise<T>;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Process-wide serialization for write operations that race when multiple scan
3
+ * agents run in parallel. Each in-flight `create_account` / `update_account_metadata`
4
+ * is held inside `runExclusive` so the SQLite write + the subsequent read-back
5
+ * by another agent's `list_accounts` are consistent.
6
+ *
7
+ * Single tail-promise queue: cheap, deterministic, no extra deps.
8
+ */
9
+ let tail = Promise.resolve();
10
+ export function runExclusive(fn) {
11
+ const next = tail.then(() => fn());
12
+ // Swallow rejection so a thrown callback doesn't poison the queue for the
13
+ // next caller. The caller still sees the rejection through `next`.
14
+ tail = next.catch(() => undefined);
15
+ return next;
16
+ }
@@ -0,0 +1,48 @@
1
+ import type Database from "libsql";
2
+ import { type JournalEntryInput } from "../db/queries/journal.js";
3
+ /**
4
+ * One scan agent's pending writes. Journal entries and concerns accumulate
5
+ * here while the LLM works; nothing hits the DB until `commit()` runs inside
6
+ * a single SQLite transaction. If `commit()` throws, the transaction rolls
7
+ * back and the DB stays exactly as it was before this file's scan began.
8
+ *
9
+ * Account writes (`create_account`, `update_account_metadata`) deliberately
10
+ * bypass the buffer — they go directly to the DB through `account_mutex` so
11
+ * concurrent agents see each other's account creations and don't duplicate.
12
+ */
13
+ export interface BufferedConcern {
14
+ /** Synthesized when the LLM called note_concern with a buffered entry_id. */
15
+ entry_id: string | null;
16
+ account_id: string | null;
17
+ prompt: string;
18
+ options?: string[];
19
+ }
20
+ export interface BufferedEntry {
21
+ /** Synthesized at queue-time so concerns can reference this entry. */
22
+ entry_id: string;
23
+ input: JournalEntryInput;
24
+ }
25
+ export declare class BufferedWriteContext {
26
+ readonly fileName: string;
27
+ readonly journalEntries: BufferedEntry[];
28
+ readonly concerns: BufferedConcern[];
29
+ doneSummary: string | null;
30
+ constructor(fileName: string);
31
+ /**
32
+ * Queue a journal entry. Returns the synthesized entry id so the agent can
33
+ * use it in subsequent note_concern calls inside the same file.
34
+ */
35
+ appendEntry(input: JournalEntryInput): string;
36
+ appendConcern(concern: BufferedConcern): void;
37
+ markDone(summary: string): void;
38
+ get isDone(): boolean;
39
+ /**
40
+ * Replay all buffered writes inside one DB transaction. `scannedFileId` is
41
+ * stamped onto every entry and concern so they're attributable to this file.
42
+ * Returns `{ entries, concerns }` counts so the caller can report them.
43
+ */
44
+ commit(db: Database.Database, scannedFileId: string): {
45
+ entries: number;
46
+ concerns: number;
47
+ };
48
+ }
@@ -0,0 +1,63 @@
1
+ import { randomUUID } from "crypto";
2
+ import { insertJournalEntryRows, validateJournalEntry, } from "../db/queries/journal.js";
3
+ import { recordConcern } from "../db/queries/concerns.js";
4
+ export class BufferedWriteContext {
5
+ fileName;
6
+ journalEntries = [];
7
+ concerns = [];
8
+ doneSummary = null;
9
+ constructor(fileName) {
10
+ this.fileName = fileName;
11
+ }
12
+ /**
13
+ * Queue a journal entry. Returns the synthesized entry id so the agent can
14
+ * use it in subsequent note_concern calls inside the same file.
15
+ */
16
+ appendEntry(input) {
17
+ const entryId = `je:${randomUUID()}`;
18
+ this.journalEntries.push({ entry_id: entryId, input });
19
+ return entryId;
20
+ }
21
+ appendConcern(concern) {
22
+ this.concerns.push(concern);
23
+ }
24
+ markDone(summary) {
25
+ this.doneSummary = summary;
26
+ }
27
+ get isDone() {
28
+ return this.doneSummary !== null;
29
+ }
30
+ /**
31
+ * Replay all buffered writes inside one DB transaction. `scannedFileId` is
32
+ * stamped onto every entry and concern so they're attributable to this file.
33
+ * Returns `{ entries, concerns }` counts so the caller can report them.
34
+ */
35
+ commit(db, scannedFileId) {
36
+ // Validate all entries up-front so a balance error throws before we open
37
+ // the transaction (clean failure with no partial state to roll back).
38
+ const validated = this.journalEntries.map(b => ({
39
+ buffered: b,
40
+ validated: validateJournalEntry({
41
+ ...b.input,
42
+ id: b.entry_id,
43
+ source_file_id: scannedFileId,
44
+ }),
45
+ }));
46
+ const tx = db.transaction(() => {
47
+ for (const { validated: v } of validated) {
48
+ insertJournalEntryRows(db, v);
49
+ }
50
+ for (const c of this.concerns) {
51
+ recordConcern(db, {
52
+ file_id: scannedFileId,
53
+ entry_id: c.entry_id,
54
+ account_id: c.account_id,
55
+ prompt: c.prompt,
56
+ options: c.options,
57
+ });
58
+ }
59
+ });
60
+ tx();
61
+ return { entries: this.journalEntries.length, concerns: this.concerns.length };
62
+ }
63
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Run an array of async task factories with a fixed concurrency bound. Resolves
3
+ * to an array of results in the same order as the input tasks (regardless of
4
+ * completion order). Any rejection settles that slot with `undefined` and the
5
+ * caller is responsible for tracking failures — but since each task is wrapped
6
+ * in `Promise.resolve()` and pushed through `try/catch`, one task throwing
7
+ * never aborts the rest of the run.
8
+ *
9
+ * No new dependency. Simple worker-pool: kicks off up to `n` tasks, then each
10
+ * worker pulls the next index from a shared cursor until the queue is drained.
11
+ */
12
+ export declare function runWithConcurrency<T>(tasks: Array<() => Promise<T>>, n: number): Promise<Array<T | {
13
+ error: unknown;
14
+ }>>;