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
package/dist/ai/tools.js
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { saveMemory, getMemories } from "./memory.js";
|
|
3
|
+
import { getAccountBalances, findAccountById, getNetWorth, getPeriodTotals, } from "../db/queries/account_balance.js";
|
|
4
|
+
import { listJournalLines, recordJournalEntry } from "../db/queries/journal.js";
|
|
5
|
+
import { searchJournalLines } from "../db/queries/search.js";
|
|
6
|
+
import { formatCurrencyAmount } from "../currency.js";
|
|
7
|
+
import { sanitizeForPrompt, sanitizeForPromptCell } from "./sanitize.js";
|
|
8
|
+
import { ALL_THAI_INSTITUTIONS, ACCOUNT_TYPE_DESCRIPTIONS, SUGGESTED_EXPENSE_SUBTYPES, SUGGESTED_INCOME_SUBTYPES, } from "../accounts/taxonomy.js";
|
|
9
|
+
const ACCOUNT_TYPES = Object.keys(ACCOUNT_TYPE_DESCRIPTIONS);
|
|
10
|
+
const COMMON_TOOLS = [
|
|
11
|
+
{
|
|
12
|
+
name: "list_accounts",
|
|
13
|
+
description: "List accounts in the chart of accounts, optionally filtered by type.",
|
|
14
|
+
input_schema: {
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: {
|
|
17
|
+
type: { type: "string", enum: ACCOUNT_TYPES, description: "Filter by account type." },
|
|
18
|
+
},
|
|
19
|
+
required: [],
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "save_memory",
|
|
24
|
+
description: "Persist a fact or bank-specific scanning hint to long-term memory.",
|
|
25
|
+
input_schema: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
content: { type: "string", description: "What to remember." },
|
|
29
|
+
category: { type: "string", description: "Category: general, scanning_hint, preference, life_event.", default: "general" },
|
|
30
|
+
},
|
|
31
|
+
required: ["content"],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "get_memories",
|
|
36
|
+
description: "Retrieve all saved long-term memories.",
|
|
37
|
+
input_schema: { type: "object", properties: {}, required: [] },
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
const SCAN_TOOLS = [
|
|
41
|
+
...COMMON_TOOLS,
|
|
42
|
+
{
|
|
43
|
+
name: "create_account",
|
|
44
|
+
description: "Create a new account in the chart of accounts when a statement reveals one that doesn't exist yet. Use a stable id like 'asset:kbank-savings-1234'.",
|
|
45
|
+
input_schema: {
|
|
46
|
+
type: "object",
|
|
47
|
+
properties: {
|
|
48
|
+
id: { type: "string", description: "Stable identifier, lowercase, colon-separated. e.g. 'asset:kbank-savings-1234'." },
|
|
49
|
+
name: { type: "string", description: "Human-readable name. e.g. 'KBank Savings ••1234'." },
|
|
50
|
+
type: { type: "string", enum: ACCOUNT_TYPES, description: "Account type." },
|
|
51
|
+
subtype: { type: "string", description: "e.g. 'bank', 'credit_card', 'salary', 'food'." },
|
|
52
|
+
bank_name: { type: "string", description: "Thai institution code from the taxonomy (e.g. KBANK, SCB, KTC)." },
|
|
53
|
+
account_number_masked: { type: "string", description: "Last 4 digits only, e.g. '••1234'." },
|
|
54
|
+
currency: { type: "string", description: "ISO 4217 code. Defaults to 'THB'.", default: "THB" },
|
|
55
|
+
due_day: { type: "number", description: "Credit-card due day of month (liabilities only)." },
|
|
56
|
+
statement_day: { type: "number", description: "Statement-cut day of month." },
|
|
57
|
+
metadata: { type: "object", description: "Free-form extra fields (e.g. {points_program: 'KTC Forever'})." },
|
|
58
|
+
},
|
|
59
|
+
required: ["id", "name", "type"],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "update_account_metadata",
|
|
64
|
+
description: "Update account metadata (due day, statement day, points balance, masked number, bank).",
|
|
65
|
+
input_schema: {
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: {
|
|
68
|
+
account_id: { type: "string" },
|
|
69
|
+
due_day: { type: "number" },
|
|
70
|
+
statement_day: { type: "number" },
|
|
71
|
+
points_balance: { type: "number" },
|
|
72
|
+
account_number_masked: { type: "string" },
|
|
73
|
+
bank_name: { type: "string" },
|
|
74
|
+
metadata: { type: "object", description: "Merged into existing metadata_json." },
|
|
75
|
+
},
|
|
76
|
+
required: ["account_id"],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "record_journal_entry",
|
|
81
|
+
description: "Post a balanced double-entry journal entry. The sum of debits MUST equal the sum of credits (within one currency). Convert Buddhist-Era dates by subtracting 543. Every line carries an ISO 4217 currency code (THB, USD, EUR, …); default to THB. Use the account's currency where set; only deviate when the source row is explicitly in another currency.",
|
|
82
|
+
input_schema: {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: {
|
|
85
|
+
date: { type: "string", description: "ISO Gregorian date (YYYY-MM-DD)." },
|
|
86
|
+
description: { type: "string", description: "Short human-readable description of the entry." },
|
|
87
|
+
source_page: { type: "number", description: "Page number in the source PDF, if known." },
|
|
88
|
+
lines: {
|
|
89
|
+
type: "array",
|
|
90
|
+
description: "Two or more journal lines that balance.",
|
|
91
|
+
items: {
|
|
92
|
+
type: "object",
|
|
93
|
+
properties: {
|
|
94
|
+
account_id: { type: "string", description: "Existing account id from list_accounts or create_account." },
|
|
95
|
+
debit: { type: "number", description: "Debit amount in this line's currency. Use 0 if this line is a credit." },
|
|
96
|
+
credit: { type: "number", description: "Credit amount in this line's currency. Use 0 if this line is a debit." },
|
|
97
|
+
currency: { type: "string", description: "ISO 4217 currency code for this line (e.g. THB, USD, EUR). Defaults to THB.", default: "THB" },
|
|
98
|
+
memo: { type: "string", description: "Optional per-line memo." },
|
|
99
|
+
},
|
|
100
|
+
required: ["account_id"],
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
required: ["date", "description", "lines"],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "ask_user",
|
|
109
|
+
description: "Ask the user a clarifying question when you cannot confidently scan the document. The pipeline pauses the file and prompts the user interactively when running `plasalid scan`.",
|
|
110
|
+
input_schema: {
|
|
111
|
+
type: "object",
|
|
112
|
+
properties: {
|
|
113
|
+
prompt: { type: "string", description: "The question to ask in plain language." },
|
|
114
|
+
options: {
|
|
115
|
+
type: "array",
|
|
116
|
+
description: "Optional list of candidate answers.",
|
|
117
|
+
items: { type: "string" },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
required: ["prompt"],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "mark_file_scanned",
|
|
125
|
+
description: "Call this once the file is fully processed and all journal entries are posted. Summary text is shown to the user.",
|
|
126
|
+
input_schema: {
|
|
127
|
+
type: "object",
|
|
128
|
+
properties: {
|
|
129
|
+
summary: { type: "string", description: "Short summary of what was recorded." },
|
|
130
|
+
},
|
|
131
|
+
required: ["summary"],
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
const CHAT_TOOLS = [
|
|
136
|
+
...COMMON_TOOLS,
|
|
137
|
+
{
|
|
138
|
+
name: "get_account_balance",
|
|
139
|
+
description: "Get balance for a single account by id.",
|
|
140
|
+
input_schema: {
|
|
141
|
+
type: "object",
|
|
142
|
+
properties: {
|
|
143
|
+
account_id: { type: "string" },
|
|
144
|
+
},
|
|
145
|
+
required: ["account_id"],
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: "get_net_worth",
|
|
150
|
+
description: "Compute current net worth: total assets minus total liabilities.",
|
|
151
|
+
input_schema: { type: "object", properties: {}, required: [] },
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: "list_journal_entries",
|
|
155
|
+
description: "List journal lines filtered by account and/or date range.",
|
|
156
|
+
input_schema: {
|
|
157
|
+
type: "object",
|
|
158
|
+
properties: {
|
|
159
|
+
account_id: { type: "string" },
|
|
160
|
+
from: { type: "string", description: "Start date YYYY-MM-DD" },
|
|
161
|
+
to: { type: "string", description: "End date YYYY-MM-DD" },
|
|
162
|
+
q: { type: "string", description: "Free-text contains-match on description or memo" },
|
|
163
|
+
limit: { type: "number", description: "Max results (default 50)" },
|
|
164
|
+
},
|
|
165
|
+
required: [],
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: "search_transactions",
|
|
170
|
+
description: "Free-text search across journal entry descriptions, line memos, and account names.",
|
|
171
|
+
input_schema: {
|
|
172
|
+
type: "object",
|
|
173
|
+
properties: {
|
|
174
|
+
query: { type: "string" },
|
|
175
|
+
limit: { type: "number", default: 30 },
|
|
176
|
+
},
|
|
177
|
+
required: ["query"],
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: "get_period_totals",
|
|
182
|
+
description: "Get total income and total expense in a date range. Useful for monthly summaries.",
|
|
183
|
+
input_schema: {
|
|
184
|
+
type: "object",
|
|
185
|
+
properties: {
|
|
186
|
+
from: { type: "string", description: "Start date YYYY-MM-DD" },
|
|
187
|
+
to: { type: "string", description: "End date YYYY-MM-DD" },
|
|
188
|
+
},
|
|
189
|
+
required: ["from", "to"],
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
export function getToolDefinitions(profile) {
|
|
194
|
+
return profile === "scan" ? SCAN_TOOLS : CHAT_TOOLS;
|
|
195
|
+
}
|
|
196
|
+
/** Human-readable labels shown in the spinner. */
|
|
197
|
+
export const TOOL_LABELS = {
|
|
198
|
+
list_accounts: "Listing accounts",
|
|
199
|
+
create_account: "Creating account",
|
|
200
|
+
update_account_metadata: "Updating account metadata",
|
|
201
|
+
record_journal_entry: "Posting journal entry",
|
|
202
|
+
ask_user: "Asking for clarification",
|
|
203
|
+
save_memory: "Saving memory",
|
|
204
|
+
get_memories: "Recalling memories",
|
|
205
|
+
mark_file_scanned: "Finalizing file",
|
|
206
|
+
get_account_balance: "Looking up balance",
|
|
207
|
+
get_net_worth: "Computing net worth",
|
|
208
|
+
list_journal_entries: "Listing journal entries",
|
|
209
|
+
search_transactions: "Searching transactions",
|
|
210
|
+
get_period_totals: "Summing period totals",
|
|
211
|
+
};
|
|
212
|
+
function formatTHB(amount) {
|
|
213
|
+
return formatCurrencyAmount(amount, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
214
|
+
}
|
|
215
|
+
export async function executeTool(db, toolName, toolInput, ctx) {
|
|
216
|
+
switch (toolName) {
|
|
217
|
+
case "list_accounts": {
|
|
218
|
+
const accounts = getAccountBalances(db, toolInput?.type ? { type: toolInput.type } : {});
|
|
219
|
+
if (accounts.length === 0)
|
|
220
|
+
return "No accounts in the chart of accounts yet.";
|
|
221
|
+
return accounts
|
|
222
|
+
.map(a => {
|
|
223
|
+
const meta = [];
|
|
224
|
+
if (a.bank_name)
|
|
225
|
+
meta.push(sanitizeForPrompt(a.bank_name));
|
|
226
|
+
if (a.account_number_masked)
|
|
227
|
+
meta.push(sanitizeForPrompt(a.account_number_masked));
|
|
228
|
+
if (a.due_day)
|
|
229
|
+
meta.push(`due day ${a.due_day}`);
|
|
230
|
+
if (a.points_balance)
|
|
231
|
+
meta.push(`${a.points_balance} pts`);
|
|
232
|
+
const metaStr = meta.length ? ` [${meta.join(" · ")}]` : "";
|
|
233
|
+
return `${a.id} | ${sanitizeForPromptCell(a.name)} | ${a.type}${a.subtype ? `/${a.subtype}` : ""} | balance ${formatTHB(a.balance)}${metaStr}`;
|
|
234
|
+
})
|
|
235
|
+
.join("\n");
|
|
236
|
+
}
|
|
237
|
+
case "get_account_balance": {
|
|
238
|
+
const acct = findAccountById(db, toolInput.account_id);
|
|
239
|
+
if (!acct)
|
|
240
|
+
return `Account "${toolInput.account_id}" not found.`;
|
|
241
|
+
const balances = getAccountBalances(db);
|
|
242
|
+
const match = balances.find(b => b.id === acct.id);
|
|
243
|
+
const bal = match?.balance ?? 0;
|
|
244
|
+
return `${sanitizeForPrompt(acct.name)} (${acct.type}): ${formatTHB(bal)}`;
|
|
245
|
+
}
|
|
246
|
+
case "get_net_worth": {
|
|
247
|
+
const nw = getNetWorth(db);
|
|
248
|
+
return `Net worth: ${formatTHB(nw.net_worth)} (assets ${formatTHB(nw.assets)} − liabilities ${formatTHB(nw.liabilities)})`;
|
|
249
|
+
}
|
|
250
|
+
case "list_journal_entries": {
|
|
251
|
+
const rows = listJournalLines(db, {
|
|
252
|
+
account_id: toolInput.account_id,
|
|
253
|
+
from: toolInput.from,
|
|
254
|
+
to: toolInput.to,
|
|
255
|
+
q: toolInput.q,
|
|
256
|
+
limit: toolInput.limit,
|
|
257
|
+
});
|
|
258
|
+
if (rows.length === 0)
|
|
259
|
+
return "No matching journal lines.";
|
|
260
|
+
return rows
|
|
261
|
+
.map(r => {
|
|
262
|
+
const dr = r.debit > 0 ? `DR ${formatTHB(r.debit)}` : "";
|
|
263
|
+
const cr = r.credit > 0 ? `CR ${formatTHB(r.credit)}` : "";
|
|
264
|
+
return `${r.entry_date} | ${sanitizeForPromptCell(r.entry_description)} | ${sanitizeForPromptCell(r.account_name || "")} | ${dr}${cr} | ${sanitizeForPromptCell(r.memo || "")}`;
|
|
265
|
+
})
|
|
266
|
+
.join("\n");
|
|
267
|
+
}
|
|
268
|
+
case "search_transactions": {
|
|
269
|
+
const rows = searchJournalLines(db, toolInput.query, toolInput.limit);
|
|
270
|
+
if (rows.length === 0)
|
|
271
|
+
return `No matches for "${sanitizeForPrompt(toolInput.query)}".`;
|
|
272
|
+
return rows
|
|
273
|
+
.map(r => {
|
|
274
|
+
const dr = r.debit > 0 ? `DR ${formatTHB(r.debit)}` : "";
|
|
275
|
+
const cr = r.credit > 0 ? `CR ${formatTHB(r.credit)}` : "";
|
|
276
|
+
return `${r.entry_date} | ${sanitizeForPromptCell(r.entry_description)} | ${sanitizeForPromptCell(r.account_name || "")} | ${dr}${cr}`;
|
|
277
|
+
})
|
|
278
|
+
.join("\n");
|
|
279
|
+
}
|
|
280
|
+
case "get_period_totals": {
|
|
281
|
+
const totals = getPeriodTotals(db, toolInput.from, toolInput.to);
|
|
282
|
+
return `Income ${formatTHB(totals.income)} · Expenses ${formatTHB(totals.expenses)} · Net ${formatTHB(totals.income - totals.expenses)}`;
|
|
283
|
+
}
|
|
284
|
+
case "save_memory": {
|
|
285
|
+
saveMemory(db, toolInput.content, toolInput.category || "general");
|
|
286
|
+
return `Saved memory: "${sanitizeForPrompt(toolInput.content)}"`;
|
|
287
|
+
}
|
|
288
|
+
case "get_memories": {
|
|
289
|
+
const memories = getMemories(db);
|
|
290
|
+
if (memories.length === 0)
|
|
291
|
+
return "No memories saved yet.";
|
|
292
|
+
return memories.map(m => `[${m.category}] ${sanitizeForPrompt(m.content)} (saved ${m.created_at})`).join("\n");
|
|
293
|
+
}
|
|
294
|
+
case "create_account": {
|
|
295
|
+
if (!ACCOUNT_TYPES.includes(toolInput.type)) {
|
|
296
|
+
return `Invalid type "${toolInput.type}". Allowed: ${ACCOUNT_TYPES.join(", ")}.`;
|
|
297
|
+
}
|
|
298
|
+
const knownCodes = new Set(ALL_THAI_INSTITUTIONS.map(i => i.code));
|
|
299
|
+
const bank = toolInput.bank_name ? String(toolInput.bank_name).toUpperCase() : null;
|
|
300
|
+
if (bank && !knownCodes.has(bank)) {
|
|
301
|
+
// Allow unknown institutions but flag them. The taxonomy is a hint, not a hard list.
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
db.prepare(`INSERT INTO accounts (id, name, type, subtype, bank_name, account_number_masked, currency, due_day, statement_day, metadata_json)
|
|
305
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(toolInput.id, toolInput.name, toolInput.type, toolInput.subtype || null, bank, toolInput.account_number_masked || null, toolInput.currency || "THB", toolInput.due_day ?? null, toolInput.statement_day ?? null, toolInput.metadata ? JSON.stringify(toolInput.metadata) : null);
|
|
306
|
+
return `Account created: ${toolInput.id} (${toolInput.name}, ${toolInput.type}).`;
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
if (String(err.message).includes("UNIQUE")) {
|
|
310
|
+
return `Account "${toolInput.id}" already exists. Use update_account_metadata to modify it.`;
|
|
311
|
+
}
|
|
312
|
+
throw err;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
case "update_account_metadata": {
|
|
316
|
+
const acct = findAccountById(db, toolInput.account_id);
|
|
317
|
+
if (!acct)
|
|
318
|
+
return `Account "${toolInput.account_id}" not found.`;
|
|
319
|
+
const updates = [];
|
|
320
|
+
const params = [];
|
|
321
|
+
if (toolInput.due_day !== undefined) {
|
|
322
|
+
updates.push("due_day = ?");
|
|
323
|
+
params.push(toolInput.due_day);
|
|
324
|
+
}
|
|
325
|
+
if (toolInput.statement_day !== undefined) {
|
|
326
|
+
updates.push("statement_day = ?");
|
|
327
|
+
params.push(toolInput.statement_day);
|
|
328
|
+
}
|
|
329
|
+
if (toolInput.points_balance !== undefined) {
|
|
330
|
+
updates.push("points_balance = ?");
|
|
331
|
+
params.push(toolInput.points_balance);
|
|
332
|
+
}
|
|
333
|
+
if (toolInput.account_number_masked !== undefined) {
|
|
334
|
+
updates.push("account_number_masked = ?");
|
|
335
|
+
params.push(toolInput.account_number_masked);
|
|
336
|
+
}
|
|
337
|
+
if (toolInput.bank_name !== undefined) {
|
|
338
|
+
updates.push("bank_name = ?");
|
|
339
|
+
params.push(String(toolInput.bank_name).toUpperCase());
|
|
340
|
+
}
|
|
341
|
+
if (toolInput.metadata) {
|
|
342
|
+
const existing = acct.metadata_json ? JSON.parse(acct.metadata_json) : {};
|
|
343
|
+
const merged = { ...existing, ...toolInput.metadata };
|
|
344
|
+
updates.push("metadata_json = ?");
|
|
345
|
+
params.push(JSON.stringify(merged));
|
|
346
|
+
}
|
|
347
|
+
if (updates.length === 0)
|
|
348
|
+
return "Nothing to update.";
|
|
349
|
+
params.push(toolInput.account_id);
|
|
350
|
+
db.prepare(`UPDATE accounts SET ${updates.join(", ")} WHERE id = ?`).run(...params);
|
|
351
|
+
return `Updated ${toolInput.account_id}.`;
|
|
352
|
+
}
|
|
353
|
+
case "record_journal_entry": {
|
|
354
|
+
if (!ctx)
|
|
355
|
+
return "record_journal_entry is only available during scan runs.";
|
|
356
|
+
try {
|
|
357
|
+
const entryId = recordJournalEntry(db, {
|
|
358
|
+
date: toolInput.date,
|
|
359
|
+
description: toolInput.description,
|
|
360
|
+
source_file_id: ctx.fileId,
|
|
361
|
+
source_page: toolInput.source_page ?? null,
|
|
362
|
+
scanner_version: ctx.scannerVersion,
|
|
363
|
+
lines: (toolInput.lines || []).map((l) => ({
|
|
364
|
+
account_id: l.account_id,
|
|
365
|
+
debit: l.debit ?? 0,
|
|
366
|
+
credit: l.credit ?? 0,
|
|
367
|
+
currency: l.currency || "THB",
|
|
368
|
+
memo: l.memo ?? null,
|
|
369
|
+
})),
|
|
370
|
+
});
|
|
371
|
+
return `Posted journal entry ${entryId} (${toolInput.date}).`;
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
return `Could not post journal entry: ${err.message}`;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
case "ask_user": {
|
|
378
|
+
if (!ctx)
|
|
379
|
+
return "ask_user is only available during scan runs.";
|
|
380
|
+
const id = `pq:${randomUUID()}`;
|
|
381
|
+
db.prepare(`INSERT INTO pending_questions (id, file_id, prompt, options_json) VALUES (?, ?, ?, ?)`).run(id, ctx.fileId, toolInput.prompt, toolInput.options ? JSON.stringify(toolInput.options) : null);
|
|
382
|
+
if (ctx.interactive && ctx.promptUser) {
|
|
383
|
+
const answer = await ctx.promptUser(toolInput.prompt, toolInput.options);
|
|
384
|
+
db.prepare(`UPDATE pending_questions SET answer = ?, resolved_at = datetime('now') WHERE id = ?`).run(answer, id);
|
|
385
|
+
return `User answered: ${sanitizeForPrompt(answer)}`;
|
|
386
|
+
}
|
|
387
|
+
return `Question recorded for later (${id}). Treat the file as awaiting input — do not post journal entries that depend on this answer.`;
|
|
388
|
+
}
|
|
389
|
+
case "mark_file_scanned": {
|
|
390
|
+
ctx?.onMarkScanned?.(toolInput.summary || "");
|
|
391
|
+
return `Marked file as scanned. Summary: ${sanitizeForPrompt(toolInput.summary || "")}`;
|
|
392
|
+
}
|
|
393
|
+
default:
|
|
394
|
+
return `Unknown tool: ${toolName}`;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
export function getTaxonomyHint() {
|
|
398
|
+
const banks = ALL_THAI_INSTITUTIONS.map(i => `${i.code} (${i.label}, ${i.kind})`).join("\n");
|
|
399
|
+
const expenses = SUGGESTED_EXPENSE_SUBTYPES.join(", ");
|
|
400
|
+
const incomes = SUGGESTED_INCOME_SUBTYPES.join(", ");
|
|
401
|
+
return `Known Thai institutions:\n${banks}\n\nSuggested expense subtypes: ${expenses}\nSuggested income subtypes: ${incomes}`;
|
|
402
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function startChat(): Promise<void>;
|
package/dist/cli/chat.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { config } from "../config.js";
|
|
3
|
+
import { banner } from "./format.js";
|
|
4
|
+
import { printLogo } from "./logo.js";
|
|
5
|
+
export async function startChat() {
|
|
6
|
+
const { getDb } = await import("../db/connection.js");
|
|
7
|
+
const db = getDb();
|
|
8
|
+
console.log("");
|
|
9
|
+
printLogo();
|
|
10
|
+
console.log("");
|
|
11
|
+
console.log(banner());
|
|
12
|
+
console.log("");
|
|
13
|
+
const accountCount = db
|
|
14
|
+
.prepare(`SELECT COUNT(*) AS n FROM accounts`)
|
|
15
|
+
.get();
|
|
16
|
+
if (accountCount.n === 0) {
|
|
17
|
+
console.log(chalk.yellow("No accounts scanned yet. Run `plasalid data` to drop your bank/credit card statements in, then run `plasalid scan`."));
|
|
18
|
+
console.log("");
|
|
19
|
+
console.log(chalk.dim("You can still chat, but I won't have any data to answer questions about."));
|
|
20
|
+
console.log("");
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
console.log(chalk.dim(`Hi ${config.userName}. Ask me anything about your financial data.`));
|
|
24
|
+
console.log("");
|
|
25
|
+
}
|
|
26
|
+
const { runChatApp } = await import("./ink/mount.js");
|
|
27
|
+
await runChatApp({ db });
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function showAccounts(): void;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { getDb } from "../../db/connection.js";
|
|
3
|
+
import { getAccountBalances } from "../../db/queries/account_balance.js";
|
|
4
|
+
import { formatCurrencyAmount } from "../../currency.js";
|
|
5
|
+
function fmtSigned(n) {
|
|
6
|
+
const body = formatCurrencyAmount(n, {
|
|
7
|
+
minimumFractionDigits: 2,
|
|
8
|
+
maximumFractionDigits: 2,
|
|
9
|
+
});
|
|
10
|
+
return n < 0 ? `-${body}` : body;
|
|
11
|
+
}
|
|
12
|
+
// eslint-disable-next-line no-control-regex
|
|
13
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
14
|
+
function visibleLength(s) {
|
|
15
|
+
return s.replace(ANSI_RE, "").length;
|
|
16
|
+
}
|
|
17
|
+
const TYPE_TAG = {
|
|
18
|
+
asset: "asset",
|
|
19
|
+
liability: "liab",
|
|
20
|
+
income: "income",
|
|
21
|
+
expense: "expense",
|
|
22
|
+
equity: "equity",
|
|
23
|
+
};
|
|
24
|
+
const TYPE_TAG_WIDTH = 8;
|
|
25
|
+
const TYPE_RANK = {
|
|
26
|
+
asset: 0,
|
|
27
|
+
liability: 1,
|
|
28
|
+
income: 2,
|
|
29
|
+
expense: 3,
|
|
30
|
+
equity: 4,
|
|
31
|
+
};
|
|
32
|
+
function compactMeta(a) {
|
|
33
|
+
const meta = [];
|
|
34
|
+
if (a.bank_name)
|
|
35
|
+
meta.push(a.bank_name);
|
|
36
|
+
if (a.due_day)
|
|
37
|
+
meta.push(`due ${a.due_day}`);
|
|
38
|
+
if (a.statement_day)
|
|
39
|
+
meta.push(`stmt ${a.statement_day}`);
|
|
40
|
+
if (a.points_balance)
|
|
41
|
+
meta.push(`${a.points_balance.toLocaleString()} pts`);
|
|
42
|
+
if (a.currency && a.currency !== "THB")
|
|
43
|
+
meta.push(a.currency);
|
|
44
|
+
// Subtype only when there's no other signal yet (e.g. "cash", "salary").
|
|
45
|
+
if (meta.length === 0 && a.subtype)
|
|
46
|
+
meta.push(a.subtype);
|
|
47
|
+
return meta;
|
|
48
|
+
}
|
|
49
|
+
export function showAccounts() {
|
|
50
|
+
const db = getDb();
|
|
51
|
+
const accounts = [...getAccountBalances(db)].sort((a, b) => {
|
|
52
|
+
const t = TYPE_RANK[a.type] - TYPE_RANK[b.type];
|
|
53
|
+
return t !== 0 ? t : a.name.localeCompare(b.name);
|
|
54
|
+
});
|
|
55
|
+
if (accounts.length === 0) {
|
|
56
|
+
console.log(chalk.yellow("No accounts yet. Drop your bank/credit card statements into ~/.plasalid/data/ and run `plasalid scan`."));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const balanceWidth = Math.max(...accounts.map((a) => fmtSigned(a.balance).length));
|
|
60
|
+
const nameWidth = Math.max(...accounts.map((a) => a.name.length));
|
|
61
|
+
for (const a of accounts) {
|
|
62
|
+
const tag = chalk.dim(TYPE_TAG[a.type].padEnd(TYPE_TAG_WIDTH));
|
|
63
|
+
const name = chalk.bold(a.name) + " ".repeat(nameWidth - a.name.length);
|
|
64
|
+
const rawBalance = fmtSigned(a.balance);
|
|
65
|
+
const coloredBalance = a.balance < 0 ? chalk.red(rawBalance) : rawBalance;
|
|
66
|
+
const paddedBalance = " ".repeat(balanceWidth - visibleLength(coloredBalance)) + coloredBalance;
|
|
67
|
+
const meta = compactMeta(a);
|
|
68
|
+
const metaStr = meta.length ? ` ${chalk.dim(meta.join(" · "))}` : "";
|
|
69
|
+
console.log(` ${tag} ${name} ${paddedBalance}${metaStr}`);
|
|
70
|
+
}
|
|
71
|
+
let assets = 0, liabilities = 0;
|
|
72
|
+
for (const a of accounts) {
|
|
73
|
+
if (a.type === "asset")
|
|
74
|
+
assets += a.balance;
|
|
75
|
+
else if (a.type === "liability")
|
|
76
|
+
liabilities += a.balance;
|
|
77
|
+
}
|
|
78
|
+
const netWorth = assets - liabilities;
|
|
79
|
+
console.log("");
|
|
80
|
+
console.log(" " +
|
|
81
|
+
chalk.dim(`Assets ${fmtSigned(assets)}`) +
|
|
82
|
+
chalk.dim(" · ") +
|
|
83
|
+
chalk.dim(`Liabilities ${fmtSigned(liabilities)}`) +
|
|
84
|
+
chalk.dim(" · ") +
|
|
85
|
+
chalk.bold(`Net worth ${fmtSigned(netWorth)}`));
|
|
86
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runDataCommand(): void;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { existsSync, mkdirSync } from "fs";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { getDataDir } from "../../config.js";
|
|
5
|
+
function openerCommand() {
|
|
6
|
+
switch (process.platform) {
|
|
7
|
+
case "darwin": return "open";
|
|
8
|
+
case "win32": return "explorer";
|
|
9
|
+
case "linux": return "xdg-open";
|
|
10
|
+
default: return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function runDataCommand() {
|
|
14
|
+
const dataDir = getDataDir();
|
|
15
|
+
if (!existsSync(dataDir))
|
|
16
|
+
mkdirSync(dataDir, { recursive: true });
|
|
17
|
+
console.log(chalk.dim(`Data folder: ${dataDir}`));
|
|
18
|
+
const cmd = openerCommand();
|
|
19
|
+
if (!cmd) {
|
|
20
|
+
console.log(chalk.yellow(`Don't know how to open the file manager on ${process.platform}. Open it manually with the path above.`));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const child = spawn(cmd, [dataDir], { stdio: "ignore", detached: true });
|
|
24
|
+
child.on("error", (err) => {
|
|
25
|
+
console.error(chalk.red(`Couldn't open the folder: ${err.message}`));
|
|
26
|
+
});
|
|
27
|
+
child.unref();
|
|
28
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { runReconcile } from "../../reconciler/pipeline.js";
|
|
3
|
+
export async function runReconcileCommand(opts) {
|
|
4
|
+
try {
|
|
5
|
+
const result = await runReconcile(opts);
|
|
6
|
+
if (result.summary) {
|
|
7
|
+
console.log("");
|
|
8
|
+
console.log(chalk.bold(result.summary));
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
catch (err) {
|
|
12
|
+
console.error(chalk.red(`Reconcile failed: ${err.message}`));
|
|
13
|
+
process.exitCode = 1;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runRevertCommand(regex: string): Promise<void>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import inquirer from "inquirer";
|
|
3
|
+
import { relative, sep } from "path";
|
|
4
|
+
import { getDb } from "../../db/connection.js";
|
|
5
|
+
import { getDataDir } from "../../config.js";
|
|
6
|
+
import { compileMatcher } from "../../scanner/pipeline.js";
|
|
7
|
+
function pathToRelPath(absolutePath) {
|
|
8
|
+
return relative(getDataDir(), absolutePath).split(sep).join("/");
|
|
9
|
+
}
|
|
10
|
+
function findRevertMatches(db, regex) {
|
|
11
|
+
const matcher = compileMatcher(regex);
|
|
12
|
+
const rows = db
|
|
13
|
+
.prepare(`SELECT id, path, scanned_at FROM scanned_files ORDER BY scanned_at DESC, created_at DESC`)
|
|
14
|
+
.all();
|
|
15
|
+
return rows
|
|
16
|
+
.map((r) => ({
|
|
17
|
+
id: r.id,
|
|
18
|
+
path: r.path,
|
|
19
|
+
relPath: pathToRelPath(r.path),
|
|
20
|
+
scannedAt: r.scanned_at,
|
|
21
|
+
}))
|
|
22
|
+
.filter((r) => matcher.test(r.relPath));
|
|
23
|
+
}
|
|
24
|
+
function deleteMatches(db, ids) {
|
|
25
|
+
if (ids.length === 0)
|
|
26
|
+
return 0;
|
|
27
|
+
const stmt = db.prepare(`DELETE FROM scanned_files WHERE id = ?`);
|
|
28
|
+
const tx = db.transaction(() => {
|
|
29
|
+
for (const id of ids)
|
|
30
|
+
stmt.run(id);
|
|
31
|
+
});
|
|
32
|
+
tx();
|
|
33
|
+
return ids.length;
|
|
34
|
+
}
|
|
35
|
+
export async function runRevertCommand(regex) {
|
|
36
|
+
if (!regex) {
|
|
37
|
+
console.error(chalk.red("revert requires a regex argument."));
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
let matches;
|
|
42
|
+
try {
|
|
43
|
+
matches = findRevertMatches(getDb(), regex);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
console.error(chalk.red(`Invalid regex: ${err.message}`));
|
|
47
|
+
process.exitCode = 1;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (matches.length === 0) {
|
|
51
|
+
console.log(chalk.dim("No scanned files match that regex."));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
console.log(chalk.bold(`revert will delete ${matches.length} file(s) and their journal entries:`));
|
|
55
|
+
for (const m of matches) {
|
|
56
|
+
const when = m.scannedAt ? chalk.dim(` (scanned ${m.scannedAt})`) : "";
|
|
57
|
+
console.log(` • ${m.relPath}${when}`);
|
|
58
|
+
}
|
|
59
|
+
const { proceed } = await inquirer.prompt([
|
|
60
|
+
{ type: "confirm", name: "proceed", message: "Proceed?", default: false },
|
|
61
|
+
]);
|
|
62
|
+
if (!proceed) {
|
|
63
|
+
console.log(chalk.dim("Cancelled."));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const deleted = deleteMatches(getDb(), matches.map((m) => m.id));
|
|
67
|
+
console.log(chalk.green(`✓ Reverted ${deleted} file(s) and all linked records.`));
|
|
68
|
+
}
|