plasalid 0.2.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 (153) hide show
  1. package/LICENSE +213 -0
  2. package/README.md +176 -0
  3. package/dist/accounts/taxonomy.d.ts +31 -0
  4. package/dist/accounts/taxonomy.js +189 -0
  5. package/dist/ai/agent.d.ts +43 -0
  6. package/dist/ai/agent.js +155 -0
  7. package/dist/ai/context.d.ts +4 -0
  8. package/dist/ai/context.js +33 -0
  9. package/dist/ai/memory.d.ts +14 -0
  10. package/dist/ai/memory.js +12 -0
  11. package/dist/ai/provider.d.ts +67 -0
  12. package/dist/ai/provider.js +5 -0
  13. package/dist/ai/providers/anthropic.d.ts +5 -0
  14. package/dist/ai/providers/anthropic.js +49 -0
  15. package/dist/ai/providers/index.d.ts +2 -0
  16. package/dist/ai/providers/index.js +12 -0
  17. package/dist/ai/providers/openai-compat.d.ts +5 -0
  18. package/dist/ai/providers/openai-compat.js +147 -0
  19. package/dist/ai/providers/openai.d.ts +5 -0
  20. package/dist/ai/providers/openai.js +147 -0
  21. package/dist/ai/redactor.d.ts +2 -0
  22. package/dist/ai/redactor.js +91 -0
  23. package/dist/ai/sanitize.d.ts +14 -0
  24. package/dist/ai/sanitize.js +25 -0
  25. package/dist/ai/system-prompt.d.ts +13 -0
  26. package/dist/ai/system-prompt.js +174 -0
  27. package/dist/ai/thai-taxonomy-hint.d.ts +8 -0
  28. package/dist/ai/thai-taxonomy-hint.js +22 -0
  29. package/dist/ai/thinking-phrases.d.ts +7 -0
  30. package/dist/ai/thinking-phrases.js +15 -0
  31. package/dist/ai/thinking.d.ts +7 -0
  32. package/dist/ai/thinking.js +15 -0
  33. package/dist/ai/tools/common.d.ts +2 -0
  34. package/dist/ai/tools/common.js +83 -0
  35. package/dist/ai/tools/index.d.ts +8 -0
  36. package/dist/ai/tools/index.js +34 -0
  37. package/dist/ai/tools/ingest.d.ts +2 -0
  38. package/dist/ai/tools/ingest.js +202 -0
  39. package/dist/ai/tools/read.d.ts +2 -0
  40. package/dist/ai/tools/read.js +123 -0
  41. package/dist/ai/tools/reconcile.d.ts +2 -0
  42. package/dist/ai/tools/reconcile.js +227 -0
  43. package/dist/ai/tools/scan.d.ts +2 -0
  44. package/dist/ai/tools/scan.js +24 -0
  45. package/dist/ai/tools/types.d.ts +26 -0
  46. package/dist/ai/tools/types.js +1 -0
  47. package/dist/ai/tools.d.ts +18 -0
  48. package/dist/ai/tools.js +402 -0
  49. package/dist/cli/chat.d.ts +1 -0
  50. package/dist/cli/chat.js +28 -0
  51. package/dist/cli/commands/accounts.d.ts +1 -0
  52. package/dist/cli/commands/accounts.js +86 -0
  53. package/dist/cli/commands/data.d.ts +1 -0
  54. package/dist/cli/commands/data.js +28 -0
  55. package/dist/cli/commands/reconcile.d.ts +2 -0
  56. package/dist/cli/commands/reconcile.js +15 -0
  57. package/dist/cli/commands/revert.d.ts +1 -0
  58. package/dist/cli/commands/revert.js +68 -0
  59. package/dist/cli/commands/scan.d.ts +4 -0
  60. package/dist/cli/commands/scan.js +45 -0
  61. package/dist/cli/commands/status.d.ts +1 -0
  62. package/dist/cli/commands/status.js +22 -0
  63. package/dist/cli/commands/transactions.d.ts +8 -0
  64. package/dist/cli/commands/transactions.js +92 -0
  65. package/dist/cli/commands/undo.d.ts +1 -0
  66. package/dist/cli/commands/undo.js +38 -0
  67. package/dist/cli/commands.d.ts +14 -0
  68. package/dist/cli/commands.js +196 -0
  69. package/dist/cli/format.d.ts +8 -0
  70. package/dist/cli/format.js +109 -0
  71. package/dist/cli/index.d.ts +2 -0
  72. package/dist/cli/index.js +126 -0
  73. package/dist/cli/ink/ChatApp.d.ts +8 -0
  74. package/dist/cli/ink/ChatApp.js +94 -0
  75. package/dist/cli/ink/PromptFrame.d.ts +10 -0
  76. package/dist/cli/ink/PromptFrame.js +11 -0
  77. package/dist/cli/ink/TextInput.d.ts +13 -0
  78. package/dist/cli/ink/TextInput.js +24 -0
  79. package/dist/cli/ink/hooks/useAgent.d.ts +27 -0
  80. package/dist/cli/ink/hooks/useAgent.js +65 -0
  81. package/dist/cli/ink/hooks/useCtrlCExit.d.ts +16 -0
  82. package/dist/cli/ink/hooks/useCtrlCExit.js +43 -0
  83. package/dist/cli/ink/hooks/useFooterText.d.ts +2 -0
  84. package/dist/cli/ink/hooks/useFooterText.js +43 -0
  85. package/dist/cli/ink/hooks/useTextInput.d.ts +32 -0
  86. package/dist/cli/ink/hooks/useTextInput.js +356 -0
  87. package/dist/cli/ink/messages/AssistantMessage.d.ts +3 -0
  88. package/dist/cli/ink/messages/AssistantMessage.js +6 -0
  89. package/dist/cli/ink/messages/ErrorMessage.d.ts +4 -0
  90. package/dist/cli/ink/messages/ErrorMessage.js +6 -0
  91. package/dist/cli/ink/messages/InterruptedMessage.d.ts +1 -0
  92. package/dist/cli/ink/messages/InterruptedMessage.js +6 -0
  93. package/dist/cli/ink/messages/ThinkingLine.d.ts +12 -0
  94. package/dist/cli/ink/messages/ThinkingLine.js +23 -0
  95. package/dist/cli/ink/messages/UserMessage.d.ts +4 -0
  96. package/dist/cli/ink/messages/UserMessage.js +15 -0
  97. package/dist/cli/ink/mount.d.ts +6 -0
  98. package/dist/cli/ink/mount.js +12 -0
  99. package/dist/cli/logo.d.ts +1 -0
  100. package/dist/cli/logo.js +20 -0
  101. package/dist/cli/setup.d.ts +2 -0
  102. package/dist/cli/setup.js +210 -0
  103. package/dist/cli/ux.d.ts +38 -0
  104. package/dist/cli/ux.js +104 -0
  105. package/dist/config.d.ts +21 -0
  106. package/dist/config.js +66 -0
  107. package/dist/currency.d.ts +6 -0
  108. package/dist/currency.js +19 -0
  109. package/dist/db/connection.d.ts +5 -0
  110. package/dist/db/connection.js +45 -0
  111. package/dist/db/encryption.d.ts +11 -0
  112. package/dist/db/encryption.js +45 -0
  113. package/dist/db/helpers.d.ts +16 -0
  114. package/dist/db/helpers.js +45 -0
  115. package/dist/db/queries/account_balance.d.ts +61 -0
  116. package/dist/db/queries/account_balance.js +146 -0
  117. package/dist/db/queries/journal.d.ts +95 -0
  118. package/dist/db/queries/journal.js +204 -0
  119. package/dist/db/queries/search.d.ts +7 -0
  120. package/dist/db/queries/search.js +19 -0
  121. package/dist/db/schema.d.ts +2 -0
  122. package/dist/db/schema.js +95 -0
  123. package/dist/index.d.ts +1 -0
  124. package/dist/index.js +1 -0
  125. package/dist/parser/pdf.d.ts +14 -0
  126. package/dist/parser/pdf.js +40 -0
  127. package/dist/parser/pipeline.d.ts +44 -0
  128. package/dist/parser/pipeline.js +160 -0
  129. package/dist/parser/prompts.d.ts +8 -0
  130. package/dist/parser/prompts.js +20 -0
  131. package/dist/parser/walker.d.ts +8 -0
  132. package/dist/parser/walker.js +42 -0
  133. package/dist/reconciler/pipeline.d.ts +17 -0
  134. package/dist/reconciler/pipeline.js +45 -0
  135. package/dist/reconciler/prompts.d.ts +12 -0
  136. package/dist/reconciler/prompts.js +22 -0
  137. package/dist/scanner/password-store.d.ts +34 -0
  138. package/dist/scanner/password-store.js +83 -0
  139. package/dist/scanner/pdf-unlock.d.ts +17 -0
  140. package/dist/scanner/pdf-unlock.js +48 -0
  141. package/dist/scanner/pdf.d.ts +17 -0
  142. package/dist/scanner/pdf.js +36 -0
  143. package/dist/scanner/pipeline.d.ts +32 -0
  144. package/dist/scanner/pipeline.js +137 -0
  145. package/dist/scanner/prompts.d.ts +8 -0
  146. package/dist/scanner/prompts.js +20 -0
  147. package/dist/scanner/state-machine.d.ts +60 -0
  148. package/dist/scanner/state-machine.js +64 -0
  149. package/dist/scanner/unlock.d.ts +24 -0
  150. package/dist/scanner/unlock.js +122 -0
  151. package/dist/scanner/walker.d.ts +8 -0
  152. package/dist/scanner/walker.js +42 -0
  153. package/package.json +65 -0
@@ -0,0 +1,61 @@
1
+ import type Database from "libsql";
2
+ export type AccountType = "asset" | "liability" | "income" | "expense" | "equity";
3
+ export interface AccountRow {
4
+ id: string;
5
+ name: string;
6
+ type: AccountType;
7
+ subtype: string | null;
8
+ bank_name: string | null;
9
+ account_number_masked: string | null;
10
+ currency: string;
11
+ due_day: number | null;
12
+ statement_day: number | null;
13
+ points_balance: number | null;
14
+ metadata_json: string | null;
15
+ pii_flag: number;
16
+ created_at: string;
17
+ }
18
+ export interface AccountBalance extends AccountRow {
19
+ balance: number;
20
+ }
21
+ /**
22
+ * Balance per account using the natural debit/credit convention:
23
+ * asset / expense → debit-normal → balance = debits − credits
24
+ * liability / income / equity → credit-normal → balance = credits − debits
25
+ */
26
+ export declare function getAccountBalances(db: Database.Database, opts?: {
27
+ type?: AccountType;
28
+ }): AccountBalance[];
29
+ export interface NetWorth {
30
+ assets: number;
31
+ liabilities: number;
32
+ net_worth: number;
33
+ }
34
+ export declare function getNetWorth(db: Database.Database): NetWorth;
35
+ export interface PeriodTotals {
36
+ income: number;
37
+ expenses: number;
38
+ }
39
+ export declare function getPeriodTotals(db: Database.Database, from: string, to: string): PeriodTotals;
40
+ export declare function findAccountById(db: Database.Database, id: string): AccountRow | null;
41
+ export declare function renameAccount(db: Database.Database, id: string, name: string): number;
42
+ /**
43
+ * Re-point every journal line on `fromId` to `toId`, then delete the source
44
+ * account. Wrapped in a transaction. Returns the number of journal lines moved.
45
+ * Throws if either account doesn't exist.
46
+ */
47
+ export declare function mergeAccounts(db: Database.Database, fromId: string, toId: string): number;
48
+ /** Delete an account only if no journal_lines reference it. */
49
+ export declare function deleteAccount(db: Database.Database, id: string): void;
50
+ export interface SimilarAccountPair {
51
+ a: AccountRow;
52
+ b: AccountRow;
53
+ similarity: number;
54
+ }
55
+ /**
56
+ * Pairwise Levenshtein similarity over `accounts.name`. Returns pairs above the
57
+ * threshold (0–1, where 1 = identical), sorted highest first. Quadratic in the
58
+ * number of accounts — fine for the small N a personal chart of accounts holds.
59
+ */
60
+ export declare function findSimilarAccounts(db: Database.Database, threshold?: number): SimilarAccountPair[];
61
+ export declare function findUnusedAccounts(db: Database.Database): AccountRow[];
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Balance per account using the natural debit/credit convention:
3
+ * asset / expense → debit-normal → balance = debits − credits
4
+ * liability / income / equity → credit-normal → balance = credits − debits
5
+ */
6
+ export function getAccountBalances(db, opts = {}) {
7
+ const params = [];
8
+ const where = [];
9
+ if (opts.type) {
10
+ where.push("a.type = ?");
11
+ params.push(opts.type);
12
+ }
13
+ const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
14
+ const rows = db.prepare(`SELECT a.*,
15
+ COALESCE(SUM(jl.debit), 0) AS sum_debit,
16
+ COALESCE(SUM(jl.credit), 0) AS sum_credit
17
+ FROM accounts a
18
+ LEFT JOIN journal_lines jl ON jl.account_id = a.id
19
+ ${whereSql}
20
+ GROUP BY a.id
21
+ ORDER BY a.type, a.name`).all(...params);
22
+ return rows.map(r => {
23
+ const debitNormal = r.type === "asset" || r.type === "expense";
24
+ const balance = debitNormal ? r.sum_debit - r.sum_credit : r.sum_credit - r.sum_debit;
25
+ const { sum_debit: _d, sum_credit: _c, ...account } = r;
26
+ return { ...account, balance };
27
+ });
28
+ }
29
+ export function getNetWorth(db) {
30
+ const balances = getAccountBalances(db);
31
+ let assets = 0;
32
+ let liabilities = 0;
33
+ for (const b of balances) {
34
+ if (b.type === "asset")
35
+ assets += b.balance;
36
+ else if (b.type === "liability")
37
+ liabilities += b.balance;
38
+ }
39
+ return { assets, liabilities, net_worth: assets - liabilities };
40
+ }
41
+ export function getPeriodTotals(db, from, to) {
42
+ const row = db.prepare(`SELECT
43
+ COALESCE(SUM(CASE WHEN a.type = 'income' THEN jl.credit - jl.debit ELSE 0 END), 0) AS income,
44
+ COALESCE(SUM(CASE WHEN a.type = 'expense' THEN jl.debit - jl.credit ELSE 0 END), 0) AS expenses
45
+ FROM journal_lines jl
46
+ JOIN journal_entries je ON je.id = jl.entry_id
47
+ JOIN accounts a ON a.id = jl.account_id
48
+ WHERE je.date BETWEEN ? AND ?`).get(from, to);
49
+ return { income: row.income, expenses: row.expenses };
50
+ }
51
+ export function findAccountById(db, id) {
52
+ return db.prepare(`SELECT * FROM accounts WHERE id = ?`).get(id) ?? null;
53
+ }
54
+ export function renameAccount(db, id, name) {
55
+ return db.prepare(`UPDATE accounts SET name = ? WHERE id = ?`).run(name, id).changes;
56
+ }
57
+ /**
58
+ * Re-point every journal line on `fromId` to `toId`, then delete the source
59
+ * account. Wrapped in a transaction. Returns the number of journal lines moved.
60
+ * Throws if either account doesn't exist.
61
+ */
62
+ export function mergeAccounts(db, fromId, toId) {
63
+ if (fromId === toId)
64
+ throw new Error("Cannot merge an account into itself.");
65
+ const from = findAccountById(db, fromId);
66
+ if (!from)
67
+ throw new Error(`Source account ${fromId} not found.`);
68
+ const to = findAccountById(db, toId);
69
+ if (!to)
70
+ throw new Error(`Destination account ${toId} not found.`);
71
+ let moved = 0;
72
+ const tx = db.transaction(() => {
73
+ moved = db
74
+ .prepare(`UPDATE journal_lines SET account_id = ? WHERE account_id = ?`)
75
+ .run(toId, fromId).changes;
76
+ db.prepare(`DELETE FROM accounts WHERE id = ?`).run(fromId);
77
+ });
78
+ tx();
79
+ return moved;
80
+ }
81
+ /** Delete an account only if no journal_lines reference it. */
82
+ export function deleteAccount(db, id) {
83
+ const inUse = db
84
+ .prepare(`SELECT 1 FROM journal_lines WHERE account_id = ? LIMIT 1`)
85
+ .get(id);
86
+ if (inUse) {
87
+ throw new Error(`Account ${id} still has journal lines; merge it first.`);
88
+ }
89
+ db.prepare(`DELETE FROM accounts WHERE id = ?`).run(id);
90
+ }
91
+ /**
92
+ * Pairwise Levenshtein similarity over `accounts.name`. Returns pairs above the
93
+ * threshold (0–1, where 1 = identical), sorted highest first. Quadratic in the
94
+ * number of accounts — fine for the small N a personal chart of accounts holds.
95
+ */
96
+ export function findSimilarAccounts(db, threshold = 0.85) {
97
+ const rows = db.prepare(`SELECT * FROM accounts ORDER BY name`).all();
98
+ const pairs = [];
99
+ for (let i = 0; i < rows.length; i++) {
100
+ for (let j = i + 1; j < rows.length; j++) {
101
+ const sim = similarity(rows[i].name.toLowerCase(), rows[j].name.toLowerCase());
102
+ if (sim >= threshold)
103
+ pairs.push({ a: rows[i], b: rows[j], similarity: Math.round(sim * 1000) / 1000 });
104
+ }
105
+ }
106
+ pairs.sort((x, y) => y.similarity - x.similarity);
107
+ return pairs;
108
+ }
109
+ export function findUnusedAccounts(db) {
110
+ return db
111
+ .prepare(`SELECT a.* FROM accounts a
112
+ LEFT JOIN journal_lines jl ON jl.account_id = a.id
113
+ WHERE jl.id IS NULL
114
+ ORDER BY a.name`)
115
+ .all();
116
+ }
117
+ // ── helpers ────────────────────────────────────────────────────────────────
118
+ function similarity(a, b) {
119
+ if (a === b)
120
+ return 1;
121
+ if (a.length === 0 || b.length === 0)
122
+ return 0;
123
+ const dist = levenshtein(a, b);
124
+ return 1 - dist / Math.max(a.length, b.length);
125
+ }
126
+ function levenshtein(a, b) {
127
+ const m = a.length, n = b.length;
128
+ if (m === 0)
129
+ return n;
130
+ if (n === 0)
131
+ return m;
132
+ const prev = new Array(n + 1);
133
+ const curr = new Array(n + 1);
134
+ for (let j = 0; j <= n; j++)
135
+ prev[j] = j;
136
+ for (let i = 1; i <= m; i++) {
137
+ curr[0] = i;
138
+ for (let j = 1; j <= n; j++) {
139
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
140
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
141
+ }
142
+ for (let j = 0; j <= n; j++)
143
+ prev[j] = curr[j];
144
+ }
145
+ return prev[n];
146
+ }
@@ -0,0 +1,95 @@
1
+ import type Database from "libsql";
2
+ export interface JournalLineInput {
3
+ account_id: string;
4
+ debit?: number;
5
+ credit?: number;
6
+ currency?: string;
7
+ memo?: string | null;
8
+ pii_flag?: boolean;
9
+ }
10
+ export interface JournalEntryInput {
11
+ date: string;
12
+ description: string;
13
+ source_file_id?: string | null;
14
+ source_page?: number | null;
15
+ lines: JournalLineInput[];
16
+ }
17
+ export interface JournalEntryRow {
18
+ id: string;
19
+ date: string;
20
+ description: string;
21
+ source_file_id: string | null;
22
+ source_page: number | null;
23
+ created_at: string;
24
+ }
25
+ export interface JournalLineRow {
26
+ id: string;
27
+ entry_id: string;
28
+ account_id: string;
29
+ debit: number;
30
+ credit: number;
31
+ currency: string;
32
+ memo: string | null;
33
+ account_name?: string;
34
+ account_type?: string;
35
+ entry_date?: string;
36
+ entry_description?: string;
37
+ }
38
+ /**
39
+ * Insert a balanced journal entry. Throws if SUM(debit) !== SUM(credit) or any
40
+ * line both debits and credits. Transaction-wrapped: lines never land without
41
+ * a header, header never lands without lines.
42
+ */
43
+ export declare function recordJournalEntry(db: Database.Database, entry: JournalEntryInput): string;
44
+ export interface ListJournalLinesOptions {
45
+ account_id?: string;
46
+ from?: string;
47
+ to?: string;
48
+ q?: string;
49
+ limit?: number;
50
+ }
51
+ export interface UpdateJournalEntryFields {
52
+ date?: string;
53
+ description?: string;
54
+ source_page?: number | null;
55
+ }
56
+ export declare function updateJournalEntry(db: Database.Database, entryId: string, fields: UpdateJournalEntryFields): number;
57
+ export interface UpdateJournalLineFields {
58
+ account_id?: string;
59
+ memo?: string | null;
60
+ }
61
+ /**
62
+ * Safe single-line edits only. Refuses changes to `debit`, `credit`, or `currency`
63
+ * because those would silently break the entry's balance — to fix amounts the
64
+ * caller must delete the entry and record a fresh one.
65
+ */
66
+ export declare function updateJournalLine(db: Database.Database, lineId: string, fields: UpdateJournalLineFields): number;
67
+ /**
68
+ * Delete a journal entry. ON DELETE CASCADE on `journal_lines.entry_id` removes
69
+ * the lines automatically.
70
+ */
71
+ export declare function deleteJournalEntry(db: Database.Database, entryId: string): number;
72
+ export interface DuplicateGroupEntry {
73
+ id: string;
74
+ date: string;
75
+ description: string;
76
+ amount: number;
77
+ account_ids: string[];
78
+ account_names: string[];
79
+ }
80
+ export interface FindDuplicateEntriesOptions {
81
+ /** Days of slack when grouping by date. 0 means same-day only. Default 2. */
82
+ toleranceDays?: number;
83
+ /** Only consider entries that have at least one line on this account. */
84
+ accountId?: string;
85
+ /** Skip entries whose total debit is below this value. */
86
+ minAmount?: number;
87
+ }
88
+ /**
89
+ * Heuristic duplicate finder: group entries by (rounded total debit) and check
90
+ * pairs whose date difference is ≤ toleranceDays. Returns groups with ≥2 members.
91
+ * Each entry carries both account_ids (for follow-up tool calls) and
92
+ * account_names (for human-readable presentation to the user).
93
+ */
94
+ export declare function findDuplicateEntries(db: Database.Database, opts?: FindDuplicateEntriesOptions): DuplicateGroupEntry[][];
95
+ export declare function listJournalLines(db: Database.Database, opts?: ListJournalLinesOptions): JournalLineRow[];
@@ -0,0 +1,204 @@
1
+ import { randomUUID } from "crypto";
2
+ const TOLERANCE = 0.005;
3
+ /**
4
+ * Insert a balanced journal entry. Throws if SUM(debit) !== SUM(credit) or any
5
+ * line both debits and credits. Transaction-wrapped: lines never land without
6
+ * a header, header never lands without lines.
7
+ */
8
+ export function recordJournalEntry(db, entry) {
9
+ if (!entry.lines || entry.lines.length < 2) {
10
+ throw new Error("Journal entry must contain at least two lines.");
11
+ }
12
+ let debitTotal = 0;
13
+ let creditTotal = 0;
14
+ for (const line of entry.lines) {
15
+ const debit = line.debit ?? 0;
16
+ const credit = line.credit ?? 0;
17
+ if (debit < 0 || credit < 0) {
18
+ throw new Error("debit and credit values must be non-negative.");
19
+ }
20
+ if (debit > 0 && credit > 0) {
21
+ throw new Error("A single journal line cannot debit and credit at the same time.");
22
+ }
23
+ if (debit === 0 && credit === 0) {
24
+ throw new Error("Each journal line must have either a debit or a credit.");
25
+ }
26
+ debitTotal += debit;
27
+ creditTotal += credit;
28
+ }
29
+ if (Math.abs(debitTotal - creditTotal) > TOLERANCE) {
30
+ throw new Error(`Journal entry does not balance: debits ${debitTotal.toFixed(2)} vs credits ${creditTotal.toFixed(2)}.`);
31
+ }
32
+ const entryId = `je:${randomUUID()}`;
33
+ const insertHeader = db.prepare(`INSERT INTO journal_entries (id, date, description, source_file_id, source_page)
34
+ VALUES (?, ?, ?, ?, ?)`);
35
+ const insertLine = db.prepare(`INSERT INTO journal_lines (id, entry_id, account_id, debit, credit, currency, memo, pii_flag)
36
+ 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;
45
+ }
46
+ export function updateJournalEntry(db, entryId, fields) {
47
+ const sets = [];
48
+ const params = [];
49
+ if (fields.date !== undefined) {
50
+ sets.push("date = ?");
51
+ params.push(fields.date);
52
+ }
53
+ if (fields.description !== undefined) {
54
+ sets.push("description = ?");
55
+ params.push(fields.description);
56
+ }
57
+ if (fields.source_page !== undefined) {
58
+ sets.push("source_page = ?");
59
+ params.push(fields.source_page);
60
+ }
61
+ if (sets.length === 0)
62
+ return 0;
63
+ params.push(entryId);
64
+ return db.prepare(`UPDATE journal_entries SET ${sets.join(", ")} WHERE id = ?`).run(...params).changes;
65
+ }
66
+ /**
67
+ * Safe single-line edits only. Refuses changes to `debit`, `credit`, or `currency`
68
+ * because those would silently break the entry's balance — to fix amounts the
69
+ * caller must delete the entry and record a fresh one.
70
+ */
71
+ export function updateJournalLine(db, lineId, fields) {
72
+ const sets = [];
73
+ const params = [];
74
+ if (fields.account_id !== undefined) {
75
+ sets.push("account_id = ?");
76
+ params.push(fields.account_id);
77
+ }
78
+ if (fields.memo !== undefined) {
79
+ sets.push("memo = ?");
80
+ params.push(fields.memo);
81
+ }
82
+ if (sets.length === 0)
83
+ return 0;
84
+ params.push(lineId);
85
+ return db.prepare(`UPDATE journal_lines SET ${sets.join(", ")} WHERE id = ?`).run(...params).changes;
86
+ }
87
+ /**
88
+ * Delete a journal entry. ON DELETE CASCADE on `journal_lines.entry_id` removes
89
+ * the lines automatically.
90
+ */
91
+ export function deleteJournalEntry(db, entryId) {
92
+ return db.prepare(`DELETE FROM journal_entries WHERE id = ?`).run(entryId).changes;
93
+ }
94
+ /**
95
+ * Heuristic duplicate finder: group entries by (rounded total debit) and check
96
+ * pairs whose date difference is ≤ toleranceDays. Returns groups with ≥2 members.
97
+ * Each entry carries both account_ids (for follow-up tool calls) and
98
+ * account_names (for human-readable presentation to the user).
99
+ */
100
+ export function findDuplicateEntries(db, opts = {}) {
101
+ const toleranceDays = Math.max(0, Math.floor(opts.toleranceDays ?? 2));
102
+ const minAmount = opts.minAmount ?? 0;
103
+ const accountFilter = opts.accountId
104
+ ? `WHERE je.id IN (SELECT entry_id FROM journal_lines WHERE account_id = ?)`
105
+ : ``;
106
+ 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
+ }
114
+ const rows = db.prepare(`SELECT je.id, je.date, je.description,
115
+ COALESCE(SUM(jl.debit), 0) AS amount,
116
+ GROUP_CONCAT(jl.account_id) AS account_ids
117
+ FROM journal_entries je
118
+ LEFT JOIN journal_lines jl ON jl.entry_id = je.id
119
+ ${accountFilter}
120
+ GROUP BY je.id`).all(...params);
121
+ const entries = rows
122
+ .filter(r => r.amount >= minAmount)
123
+ .map(r => {
124
+ const ids = (r.account_ids ?? "").split(",").filter(Boolean);
125
+ return {
126
+ id: r.id,
127
+ date: r.date,
128
+ description: r.description,
129
+ amount: Math.round(r.amount * 100) / 100,
130
+ account_ids: ids,
131
+ account_names: ids.map(id => nameById.get(id) ?? id),
132
+ };
133
+ });
134
+ const byAmount = new Map();
135
+ for (const e of entries) {
136
+ const key = Math.round(e.amount * 100); // cents
137
+ const arr = byAmount.get(key) ?? [];
138
+ arr.push(e);
139
+ byAmount.set(key, arr);
140
+ }
141
+ const groups = [];
142
+ for (const candidates of byAmount.values()) {
143
+ if (candidates.length < 2)
144
+ continue;
145
+ candidates.sort((a, b) => a.date.localeCompare(b.date));
146
+ let current = [];
147
+ for (const e of candidates) {
148
+ if (current.length === 0) {
149
+ current.push(e);
150
+ continue;
151
+ }
152
+ const last = current[current.length - 1];
153
+ if (dayDiff(last.date, e.date) <= toleranceDays) {
154
+ current.push(e);
155
+ }
156
+ else {
157
+ if (current.length >= 2)
158
+ groups.push(current);
159
+ current = [e];
160
+ }
161
+ }
162
+ if (current.length >= 2)
163
+ groups.push(current);
164
+ }
165
+ return groups;
166
+ }
167
+ function dayDiff(a, b) {
168
+ const aDate = Date.parse(a);
169
+ const bDate = Date.parse(b);
170
+ if (Number.isNaN(aDate) || Number.isNaN(bDate))
171
+ return Number.POSITIVE_INFINITY;
172
+ return Math.abs(Math.round((bDate - aDate) / 86_400_000));
173
+ }
174
+ export function listJournalLines(db, opts = {}) {
175
+ const conditions = [];
176
+ const params = [];
177
+ if (opts.account_id) {
178
+ conditions.push("jl.account_id = ?");
179
+ params.push(opts.account_id);
180
+ }
181
+ if (opts.from) {
182
+ conditions.push("je.date >= ?");
183
+ params.push(opts.from);
184
+ }
185
+ if (opts.to) {
186
+ conditions.push("je.date <= ?");
187
+ params.push(opts.to);
188
+ }
189
+ if (opts.q) {
190
+ conditions.push("(je.description LIKE ? OR jl.memo LIKE ?)");
191
+ params.push(`%${opts.q}%`, `%${opts.q}%`);
192
+ }
193
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
194
+ const limit = Math.min(Math.max(opts.limit ?? 50, 1), 500);
195
+ return db.prepare(`SELECT jl.id, jl.entry_id, jl.account_id, jl.debit, jl.credit, jl.currency, jl.memo,
196
+ a.name AS account_name, a.type AS account_type,
197
+ je.date AS entry_date, je.description AS entry_description
198
+ FROM journal_lines jl
199
+ JOIN journal_entries je ON je.id = jl.entry_id
200
+ JOIN accounts a ON a.id = jl.account_id
201
+ ${where}
202
+ ORDER BY je.date DESC, je.id DESC
203
+ LIMIT ?`).all(...params, limit);
204
+ }
@@ -0,0 +1,7 @@
1
+ import type Database from "libsql";
2
+ import type { JournalLineRow } from "./journal.js";
3
+ /**
4
+ * Free-text search across journal entry descriptions, line memos, and account
5
+ * names. Returns matching journal lines joined with account + entry metadata.
6
+ */
7
+ export declare function searchJournalLines(db: Database.Database, query: string, limit?: number): JournalLineRow[];
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Free-text search across journal entry descriptions, line memos, and account
3
+ * names. Returns matching journal lines joined with account + entry metadata.
4
+ */
5
+ export function searchJournalLines(db, query, limit = 30) {
6
+ const needle = `%${query}%`;
7
+ const capped = Math.min(Math.max(limit, 1), 200);
8
+ return db.prepare(`SELECT jl.id, jl.entry_id, jl.account_id, jl.debit, jl.credit, jl.currency, jl.memo,
9
+ a.name AS account_name, a.type AS account_type,
10
+ je.date AS entry_date, je.description AS entry_description
11
+ FROM journal_lines jl
12
+ JOIN journal_entries je ON je.id = jl.entry_id
13
+ JOIN accounts a ON a.id = jl.account_id
14
+ WHERE je.description LIKE ?
15
+ OR jl.memo LIKE ?
16
+ OR a.name LIKE ?
17
+ ORDER BY je.date DESC, je.id DESC
18
+ LIMIT ?`).all(needle, needle, needle, capped);
19
+ }
@@ -0,0 +1,2 @@
1
+ import type Database from "libsql";
2
+ export declare function migrate(db: Database.Database): void;
@@ -0,0 +1,95 @@
1
+ export function migrate(db) {
2
+ db.exec(`
3
+ CREATE TABLE IF NOT EXISTS accounts (
4
+ id TEXT PRIMARY KEY,
5
+ name TEXT NOT NULL,
6
+ type TEXT NOT NULL CHECK(type IN ('asset','liability','income','expense','equity')),
7
+ subtype TEXT,
8
+ bank_name TEXT,
9
+ account_number_masked TEXT,
10
+ currency TEXT NOT NULL DEFAULT 'THB',
11
+ due_day INTEGER,
12
+ statement_day INTEGER,
13
+ points_balance REAL,
14
+ metadata_json TEXT,
15
+ pii_flag INTEGER NOT NULL DEFAULT 0,
16
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
17
+ );
18
+
19
+ CREATE TABLE IF NOT EXISTS scanned_files (
20
+ id TEXT PRIMARY KEY,
21
+ path TEXT NOT NULL,
22
+ file_hash TEXT NOT NULL UNIQUE,
23
+ mime TEXT NOT NULL,
24
+ status TEXT NOT NULL CHECK(status IN ('pending','scanned','needs_input','failed')),
25
+ raw_text TEXT,
26
+ scanned_at TEXT,
27
+ error TEXT,
28
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
29
+ );
30
+
31
+ CREATE TABLE IF NOT EXISTS journal_entries (
32
+ id TEXT PRIMARY KEY,
33
+ date TEXT NOT NULL,
34
+ description TEXT NOT NULL,
35
+ source_file_id TEXT REFERENCES scanned_files(id) ON DELETE CASCADE,
36
+ source_page INTEGER,
37
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
38
+ );
39
+
40
+ CREATE TABLE IF NOT EXISTS journal_lines (
41
+ id TEXT PRIMARY KEY,
42
+ entry_id TEXT NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
43
+ account_id TEXT NOT NULL REFERENCES accounts(id),
44
+ debit REAL NOT NULL DEFAULT 0,
45
+ credit REAL NOT NULL DEFAULT 0,
46
+ currency TEXT NOT NULL DEFAULT 'THB',
47
+ memo TEXT,
48
+ pii_flag INTEGER NOT NULL DEFAULT 0,
49
+ CHECK (debit >= 0 AND credit >= 0 AND (debit = 0 OR credit = 0))
50
+ );
51
+
52
+ CREATE INDEX IF NOT EXISTS journal_lines_entry_idx ON journal_lines(entry_id);
53
+ CREATE INDEX IF NOT EXISTS journal_lines_account_idx ON journal_lines(account_id);
54
+ CREATE INDEX IF NOT EXISTS journal_entries_source_file_idx ON journal_entries(source_file_id);
55
+ CREATE INDEX IF NOT EXISTS journal_entries_date_idx ON journal_entries(date);
56
+
57
+ CREATE TABLE IF NOT EXISTS pending_questions (
58
+ id TEXT PRIMARY KEY,
59
+ file_id TEXT REFERENCES scanned_files(id) ON DELETE CASCADE,
60
+ prompt TEXT NOT NULL,
61
+ options_json TEXT,
62
+ answer TEXT,
63
+ resolved_at TEXT,
64
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
65
+ );
66
+
67
+ CREATE TABLE IF NOT EXISTS conversation_history (
68
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
69
+ role TEXT NOT NULL,
70
+ content TEXT NOT NULL,
71
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
72
+ );
73
+
74
+ CREATE TABLE IF NOT EXISTS memories (
75
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
76
+ content TEXT NOT NULL,
77
+ category TEXT NOT NULL DEFAULT 'general',
78
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
79
+ );
80
+
81
+ CREATE TABLE IF NOT EXISTS settings (
82
+ key TEXT PRIMARY KEY,
83
+ value TEXT NOT NULL
84
+ );
85
+
86
+ CREATE TABLE IF NOT EXISTS file_passwords (
87
+ id TEXT PRIMARY KEY,
88
+ pattern TEXT NOT NULL UNIQUE,
89
+ password_encrypted TEXT NOT NULL,
90
+ last_used_at TEXT,
91
+ use_count INTEGER NOT NULL DEFAULT 0,
92
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
93
+ );
94
+ `);
95
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import type { DocumentBlock } from "../ai/provider.js";
2
+ export interface LoadedDocument {
3
+ block: DocumentBlock;
4
+ hash: string;
5
+ mime: string;
6
+ byteLength: number;
7
+ fileName: string;
8
+ }
9
+ /**
10
+ * Read a local file, hash it, and produce a base64 document block ready to
11
+ * attach to an Anthropic user message. Hash is sha256 of the raw bytes; used as
12
+ * the idempotency key in `parsed_files`.
13
+ */
14
+ export declare function loadDocument(path: string): LoadedDocument;