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.
- package/LICENSE +213 -0
- package/README.md +176 -0
- package/dist/accounts/taxonomy.d.ts +31 -0
- package/dist/accounts/taxonomy.js +189 -0
- package/dist/ai/agent.d.ts +43 -0
- package/dist/ai/agent.js +155 -0
- package/dist/ai/context.d.ts +4 -0
- package/dist/ai/context.js +33 -0
- package/dist/ai/memory.d.ts +14 -0
- package/dist/ai/memory.js +12 -0
- package/dist/ai/provider.d.ts +67 -0
- package/dist/ai/provider.js +5 -0
- package/dist/ai/providers/anthropic.d.ts +5 -0
- package/dist/ai/providers/anthropic.js +49 -0
- package/dist/ai/providers/index.d.ts +2 -0
- package/dist/ai/providers/index.js +12 -0
- package/dist/ai/providers/openai-compat.d.ts +5 -0
- package/dist/ai/providers/openai-compat.js +147 -0
- package/dist/ai/providers/openai.d.ts +5 -0
- package/dist/ai/providers/openai.js +147 -0
- package/dist/ai/redactor.d.ts +2 -0
- package/dist/ai/redactor.js +91 -0
- package/dist/ai/sanitize.d.ts +14 -0
- package/dist/ai/sanitize.js +25 -0
- package/dist/ai/system-prompt.d.ts +13 -0
- package/dist/ai/system-prompt.js +174 -0
- package/dist/ai/thai-taxonomy-hint.d.ts +8 -0
- package/dist/ai/thai-taxonomy-hint.js +22 -0
- package/dist/ai/thinking-phrases.d.ts +7 -0
- package/dist/ai/thinking-phrases.js +15 -0
- package/dist/ai/thinking.d.ts +7 -0
- package/dist/ai/thinking.js +15 -0
- package/dist/ai/tools/common.d.ts +2 -0
- package/dist/ai/tools/common.js +83 -0
- package/dist/ai/tools/index.d.ts +8 -0
- package/dist/ai/tools/index.js +34 -0
- package/dist/ai/tools/ingest.d.ts +2 -0
- package/dist/ai/tools/ingest.js +202 -0
- package/dist/ai/tools/read.d.ts +2 -0
- package/dist/ai/tools/read.js +123 -0
- package/dist/ai/tools/reconcile.d.ts +2 -0
- package/dist/ai/tools/reconcile.js +227 -0
- package/dist/ai/tools/scan.d.ts +2 -0
- package/dist/ai/tools/scan.js +24 -0
- package/dist/ai/tools/types.d.ts +26 -0
- package/dist/ai/tools/types.js +1 -0
- package/dist/ai/tools.d.ts +18 -0
- package/dist/ai/tools.js +402 -0
- package/dist/cli/chat.d.ts +1 -0
- package/dist/cli/chat.js +28 -0
- package/dist/cli/commands/accounts.d.ts +1 -0
- package/dist/cli/commands/accounts.js +86 -0
- package/dist/cli/commands/data.d.ts +1 -0
- package/dist/cli/commands/data.js +28 -0
- package/dist/cli/commands/reconcile.d.ts +2 -0
- package/dist/cli/commands/reconcile.js +15 -0
- package/dist/cli/commands/revert.d.ts +1 -0
- package/dist/cli/commands/revert.js +68 -0
- package/dist/cli/commands/scan.d.ts +4 -0
- package/dist/cli/commands/scan.js +45 -0
- package/dist/cli/commands/status.d.ts +1 -0
- package/dist/cli/commands/status.js +22 -0
- package/dist/cli/commands/transactions.d.ts +8 -0
- package/dist/cli/commands/transactions.js +92 -0
- package/dist/cli/commands/undo.d.ts +1 -0
- package/dist/cli/commands/undo.js +38 -0
- package/dist/cli/commands.d.ts +14 -0
- package/dist/cli/commands.js +196 -0
- package/dist/cli/format.d.ts +8 -0
- package/dist/cli/format.js +109 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +126 -0
- package/dist/cli/ink/ChatApp.d.ts +8 -0
- package/dist/cli/ink/ChatApp.js +94 -0
- package/dist/cli/ink/PromptFrame.d.ts +10 -0
- package/dist/cli/ink/PromptFrame.js +11 -0
- package/dist/cli/ink/TextInput.d.ts +13 -0
- package/dist/cli/ink/TextInput.js +24 -0
- package/dist/cli/ink/hooks/useAgent.d.ts +27 -0
- package/dist/cli/ink/hooks/useAgent.js +65 -0
- package/dist/cli/ink/hooks/useCtrlCExit.d.ts +16 -0
- package/dist/cli/ink/hooks/useCtrlCExit.js +43 -0
- package/dist/cli/ink/hooks/useFooterText.d.ts +2 -0
- package/dist/cli/ink/hooks/useFooterText.js +43 -0
- package/dist/cli/ink/hooks/useTextInput.d.ts +32 -0
- package/dist/cli/ink/hooks/useTextInput.js +356 -0
- package/dist/cli/ink/messages/AssistantMessage.d.ts +3 -0
- package/dist/cli/ink/messages/AssistantMessage.js +6 -0
- package/dist/cli/ink/messages/ErrorMessage.d.ts +4 -0
- package/dist/cli/ink/messages/ErrorMessage.js +6 -0
- package/dist/cli/ink/messages/InterruptedMessage.d.ts +1 -0
- package/dist/cli/ink/messages/InterruptedMessage.js +6 -0
- package/dist/cli/ink/messages/ThinkingLine.d.ts +12 -0
- package/dist/cli/ink/messages/ThinkingLine.js +23 -0
- package/dist/cli/ink/messages/UserMessage.d.ts +4 -0
- package/dist/cli/ink/messages/UserMessage.js +15 -0
- package/dist/cli/ink/mount.d.ts +6 -0
- package/dist/cli/ink/mount.js +12 -0
- package/dist/cli/logo.d.ts +1 -0
- package/dist/cli/logo.js +20 -0
- package/dist/cli/setup.d.ts +2 -0
- package/dist/cli/setup.js +210 -0
- package/dist/cli/ux.d.ts +38 -0
- package/dist/cli/ux.js +104 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.js +66 -0
- package/dist/currency.d.ts +6 -0
- package/dist/currency.js +19 -0
- package/dist/db/connection.d.ts +5 -0
- package/dist/db/connection.js +45 -0
- package/dist/db/encryption.d.ts +11 -0
- package/dist/db/encryption.js +45 -0
- package/dist/db/helpers.d.ts +16 -0
- package/dist/db/helpers.js +45 -0
- package/dist/db/queries/account_balance.d.ts +61 -0
- package/dist/db/queries/account_balance.js +146 -0
- package/dist/db/queries/journal.d.ts +95 -0
- package/dist/db/queries/journal.js +204 -0
- package/dist/db/queries/search.d.ts +7 -0
- package/dist/db/queries/search.js +19 -0
- package/dist/db/schema.d.ts +2 -0
- package/dist/db/schema.js +95 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/parser/pdf.d.ts +14 -0
- package/dist/parser/pdf.js +40 -0
- package/dist/parser/pipeline.d.ts +44 -0
- package/dist/parser/pipeline.js +160 -0
- package/dist/parser/prompts.d.ts +8 -0
- package/dist/parser/prompts.js +20 -0
- package/dist/parser/walker.d.ts +8 -0
- package/dist/parser/walker.js +42 -0
- package/dist/reconciler/pipeline.d.ts +17 -0
- package/dist/reconciler/pipeline.js +45 -0
- package/dist/reconciler/prompts.d.ts +12 -0
- package/dist/reconciler/prompts.js +22 -0
- package/dist/scanner/password-store.d.ts +34 -0
- package/dist/scanner/password-store.js +83 -0
- package/dist/scanner/pdf-unlock.d.ts +17 -0
- package/dist/scanner/pdf-unlock.js +48 -0
- package/dist/scanner/pdf.d.ts +17 -0
- package/dist/scanner/pdf.js +36 -0
- package/dist/scanner/pipeline.d.ts +32 -0
- package/dist/scanner/pipeline.js +137 -0
- package/dist/scanner/prompts.d.ts +8 -0
- package/dist/scanner/prompts.js +20 -0
- package/dist/scanner/state-machine.d.ts +60 -0
- package/dist/scanner/state-machine.js +64 -0
- package/dist/scanner/unlock.d.ts +24 -0
- package/dist/scanner/unlock.js +122 -0
- package/dist/scanner/walker.d.ts +8 -0
- package/dist/scanner/walker.js +42 -0
- 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,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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|