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,202 @@
1
+ import { randomUUID } from "crypto";
2
+ import { findAccountById } from "../../db/queries/account_balance.js";
3
+ import { recordJournalEntry } from "../../db/queries/journal.js";
4
+ import { sanitizeForPrompt } from "../sanitize.js";
5
+ import { ALL_THAI_INSTITUTIONS, ACCOUNT_TYPE_DESCRIPTIONS, } from "../../accounts/taxonomy.js";
6
+ const ACCOUNT_TYPES = Object.keys(ACCOUNT_TYPE_DESCRIPTIONS);
7
+ const DEFS = [
8
+ {
9
+ name: "create_account",
10
+ 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'.",
11
+ input_schema: {
12
+ type: "object",
13
+ properties: {
14
+ id: { type: "string", description: "Stable identifier, lowercase, colon-separated. e.g. 'asset:kbank-savings-1234'." },
15
+ name: { type: "string", description: "Human-readable name. e.g. 'KBank Savings ••1234'." },
16
+ type: { type: "string", enum: ACCOUNT_TYPES, description: "Account type." },
17
+ subtype: { type: "string", description: "e.g. 'bank', 'credit_card', 'salary', 'food'." },
18
+ bank_name: { type: "string", description: "Thai institution code from the taxonomy (e.g. KBANK, SCB, KTC)." },
19
+ account_number_masked: { type: "string", description: "Last 4 digits only, e.g. '••1234'." },
20
+ currency: { type: "string", description: "ISO 4217 code. Defaults to 'THB'.", default: "THB" },
21
+ due_day: { type: "number", description: "Credit-card due day of month (liabilities only)." },
22
+ statement_day: { type: "number", description: "Statement-cut day of month." },
23
+ metadata: { type: "object", description: "Free-form extra fields (e.g. {points_program: 'KTC Forever'})." },
24
+ },
25
+ required: ["id", "name", "type"],
26
+ },
27
+ },
28
+ {
29
+ name: "update_account_metadata",
30
+ description: "Update account metadata (due day, statement day, points balance, masked number, bank).",
31
+ input_schema: {
32
+ type: "object",
33
+ properties: {
34
+ account_id: { type: "string" },
35
+ due_day: { type: "number" },
36
+ statement_day: { type: "number" },
37
+ points_balance: { type: "number" },
38
+ account_number_masked: { type: "string" },
39
+ bank_name: { type: "string" },
40
+ metadata: { type: "object", description: "Merged into existing metadata_json." },
41
+ },
42
+ required: ["account_id"],
43
+ },
44
+ },
45
+ {
46
+ name: "record_journal_entry",
47
+ 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.",
48
+ input_schema: {
49
+ type: "object",
50
+ properties: {
51
+ date: { type: "string", description: "ISO Gregorian date (YYYY-MM-DD)." },
52
+ description: { type: "string", description: "Short human-readable description of the entry." },
53
+ source_page: { type: "number", description: "Page number in the source PDF, if known." },
54
+ lines: {
55
+ type: "array",
56
+ description: "Two or more journal lines that balance.",
57
+ items: {
58
+ type: "object",
59
+ properties: {
60
+ account_id: { type: "string", description: "Existing account id from list_accounts or create_account." },
61
+ debit: { type: "number", description: "Debit amount in this line's currency. Use 0 if this line is a credit." },
62
+ credit: { type: "number", description: "Credit amount in this line's currency. Use 0 if this line is a debit." },
63
+ currency: { type: "string", description: "ISO 4217 currency code for this line (e.g. THB, USD, EUR). Defaults to THB.", default: "THB" },
64
+ memo: { type: "string", description: "Optional per-line memo." },
65
+ },
66
+ required: ["account_id"],
67
+ },
68
+ },
69
+ },
70
+ required: ["date", "description", "lines"],
71
+ },
72
+ },
73
+ {
74
+ name: "ask_user",
75
+ description: "Ask the user a clarifying question when you cannot confidently proceed. The pipeline pauses and prompts the user interactively when running `plasalid scan` or `plasalid reconcile`.",
76
+ input_schema: {
77
+ type: "object",
78
+ properties: {
79
+ prompt: { type: "string", description: "The question to ask in plain language." },
80
+ options: {
81
+ type: "array",
82
+ description: "Optional list of candidate answers.",
83
+ items: { type: "string" },
84
+ },
85
+ },
86
+ required: ["prompt"],
87
+ },
88
+ },
89
+ ];
90
+ const LABELS = {
91
+ create_account: "Creating account",
92
+ update_account_metadata: "Updating account metadata",
93
+ record_journal_entry: "Posting journal entry",
94
+ ask_user: "Asking for clarification",
95
+ };
96
+ async function execute(db, name, input, ctx) {
97
+ switch (name) {
98
+ case "create_account": {
99
+ if (ctx?.dryRun)
100
+ return `Would create account ${input.id}.`;
101
+ if (!ACCOUNT_TYPES.includes(input.type)) {
102
+ return `Invalid type "${input.type}". Allowed: ${ACCOUNT_TYPES.join(", ")}.`;
103
+ }
104
+ const knownCodes = new Set(ALL_THAI_INSTITUTIONS.map(i => i.code));
105
+ const bank = input.bank_name ? String(input.bank_name).toUpperCase() : null;
106
+ if (bank && !knownCodes.has(bank)) {
107
+ // Allow unknown institutions; taxonomy is a hint, not a hard list.
108
+ }
109
+ try {
110
+ db.prepare(`INSERT INTO accounts (id, name, type, subtype, bank_name, account_number_masked, currency, due_day, statement_day, metadata_json)
111
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.id, input.name, input.type, input.subtype || null, bank, input.account_number_masked || null, input.currency || "THB", input.due_day ?? null, input.statement_day ?? null, input.metadata ? JSON.stringify(input.metadata) : null);
112
+ return `Account created: ${input.id} (${input.name}, ${input.type}).`;
113
+ }
114
+ catch (err) {
115
+ if (String(err.message).includes("UNIQUE")) {
116
+ return `Account "${input.id}" already exists. Use update_account_metadata to modify it.`;
117
+ }
118
+ throw err;
119
+ }
120
+ }
121
+ case "update_account_metadata": {
122
+ if (ctx?.dryRun)
123
+ return `Would update metadata for ${input.account_id}: ${JSON.stringify(input)}`;
124
+ const acct = findAccountById(db, input.account_id);
125
+ if (!acct)
126
+ return `Account "${input.account_id}" not found.`;
127
+ const updates = [];
128
+ const params = [];
129
+ if (input.due_day !== undefined) {
130
+ updates.push("due_day = ?");
131
+ params.push(input.due_day);
132
+ }
133
+ if (input.statement_day !== undefined) {
134
+ updates.push("statement_day = ?");
135
+ params.push(input.statement_day);
136
+ }
137
+ if (input.points_balance !== undefined) {
138
+ updates.push("points_balance = ?");
139
+ params.push(input.points_balance);
140
+ }
141
+ if (input.account_number_masked !== undefined) {
142
+ updates.push("account_number_masked = ?");
143
+ params.push(input.account_number_masked);
144
+ }
145
+ if (input.bank_name !== undefined) {
146
+ updates.push("bank_name = ?");
147
+ params.push(String(input.bank_name).toUpperCase());
148
+ }
149
+ if (input.metadata) {
150
+ const existing = acct.metadata_json ? JSON.parse(acct.metadata_json) : {};
151
+ const merged = { ...existing, ...input.metadata };
152
+ updates.push("metadata_json = ?");
153
+ params.push(JSON.stringify(merged));
154
+ }
155
+ if (updates.length === 0)
156
+ return "Nothing to update.";
157
+ params.push(input.account_id);
158
+ db.prepare(`UPDATE accounts SET ${updates.join(", ")} WHERE id = ?`).run(...params);
159
+ return `Updated ${input.account_id}.`;
160
+ }
161
+ case "record_journal_entry": {
162
+ if (!ctx)
163
+ return "record_journal_entry is only available inside an agent session.";
164
+ if (ctx.dryRun)
165
+ return `Would post journal entry "${input.description}" on ${input.date}.`;
166
+ try {
167
+ const entryId = recordJournalEntry(db, {
168
+ date: input.date,
169
+ description: input.description,
170
+ source_file_id: ctx.fileId,
171
+ source_page: input.source_page ?? null,
172
+ lines: (input.lines || []).map((l) => ({
173
+ account_id: l.account_id,
174
+ debit: l.debit ?? 0,
175
+ credit: l.credit ?? 0,
176
+ currency: l.currency || "THB",
177
+ memo: l.memo ?? null,
178
+ })),
179
+ });
180
+ return `Posted journal entry ${entryId} (${input.date}).`;
181
+ }
182
+ catch (err) {
183
+ return `Could not post journal entry: ${err.message}`;
184
+ }
185
+ }
186
+ case "ask_user": {
187
+ if (!ctx)
188
+ return "ask_user is only available inside an agent session.";
189
+ const id = `pq:${randomUUID()}`;
190
+ db.prepare(`INSERT INTO pending_questions (id, file_id, prompt, options_json) VALUES (?, ?, ?, ?)`).run(id, ctx.fileId ?? null, input.prompt, input.options ? JSON.stringify(input.options) : null);
191
+ if (ctx.interactive && ctx.promptUser) {
192
+ const answer = await ctx.promptUser(input.prompt, input.options);
193
+ db.prepare(`UPDATE pending_questions SET answer = ?, resolved_at = datetime('now') WHERE id = ?`).run(answer, id);
194
+ return `User answered: ${sanitizeForPrompt(answer)}`;
195
+ }
196
+ return `Question recorded for later (${id}). Awaiting user input — do not act on assumptions about this answer.`;
197
+ }
198
+ default:
199
+ return undefined;
200
+ }
201
+ }
202
+ export const ingestTools = { DEFS, LABELS, execute };
@@ -0,0 +1,2 @@
1
+ import type { ToolModule } from "./types.js";
2
+ export declare const readTools: ToolModule;
@@ -0,0 +1,123 @@
1
+ import { findAccountById, getAccountBalances, getNetWorth, getPeriodTotals, } from "../../db/queries/account_balance.js";
2
+ import { listJournalLines } from "../../db/queries/journal.js";
3
+ import { searchJournalLines } from "../../db/queries/search.js";
4
+ import { formatCurrencyAmount } from "../../currency.js";
5
+ import { sanitizeForPrompt, sanitizeForPromptCell } from "../sanitize.js";
6
+ function formatTHB(amount) {
7
+ return formatCurrencyAmount(amount, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
8
+ }
9
+ const DEFS = [
10
+ {
11
+ name: "get_account_balance",
12
+ description: "Get balance for a single account by id.",
13
+ input_schema: {
14
+ type: "object",
15
+ properties: { account_id: { type: "string" } },
16
+ required: ["account_id"],
17
+ },
18
+ },
19
+ {
20
+ name: "get_net_worth",
21
+ description: "Compute current net worth: total assets minus total liabilities.",
22
+ input_schema: { type: "object", properties: {}, required: [] },
23
+ },
24
+ {
25
+ name: "list_journal_entries",
26
+ description: "List journal lines filtered by account and/or date range.",
27
+ input_schema: {
28
+ type: "object",
29
+ properties: {
30
+ account_id: { type: "string" },
31
+ from: { type: "string", description: "Start date YYYY-MM-DD" },
32
+ to: { type: "string", description: "End date YYYY-MM-DD" },
33
+ q: { type: "string", description: "Free-text contains-match on description or memo" },
34
+ limit: { type: "number", description: "Max results (default 50)" },
35
+ },
36
+ required: [],
37
+ },
38
+ },
39
+ {
40
+ name: "search_transactions",
41
+ description: "Free-text search across journal entry descriptions, line memos, and account names.",
42
+ input_schema: {
43
+ type: "object",
44
+ properties: {
45
+ query: { type: "string" },
46
+ limit: { type: "number", default: 30 },
47
+ },
48
+ required: ["query"],
49
+ },
50
+ },
51
+ {
52
+ name: "get_period_totals",
53
+ description: "Get total income and total expense in a date range. Useful for monthly summaries.",
54
+ input_schema: {
55
+ type: "object",
56
+ properties: {
57
+ from: { type: "string", description: "Start date YYYY-MM-DD" },
58
+ to: { type: "string", description: "End date YYYY-MM-DD" },
59
+ },
60
+ required: ["from", "to"],
61
+ },
62
+ },
63
+ ];
64
+ const LABELS = {
65
+ get_account_balance: "Looking up balance",
66
+ get_net_worth: "Computing net worth",
67
+ list_journal_entries: "Listing journal entries",
68
+ search_transactions: "Searching transactions",
69
+ get_period_totals: "Summing period totals",
70
+ };
71
+ async function execute(db, name, input, _ctx) {
72
+ switch (name) {
73
+ case "get_account_balance": {
74
+ const acct = findAccountById(db, input.account_id);
75
+ if (!acct)
76
+ return `Account "${input.account_id}" not found.`;
77
+ const balances = getAccountBalances(db);
78
+ const bal = balances.find(b => b.id === acct.id)?.balance ?? 0;
79
+ return `${sanitizeForPrompt(acct.name)} (${acct.type}): ${formatTHB(bal)}`;
80
+ }
81
+ case "get_net_worth": {
82
+ const nw = getNetWorth(db);
83
+ return `Net worth: ${formatTHB(nw.net_worth)} (assets ${formatTHB(nw.assets)} − liabilities ${formatTHB(nw.liabilities)})`;
84
+ }
85
+ case "list_journal_entries": {
86
+ const rows = listJournalLines(db, {
87
+ account_id: input.account_id,
88
+ from: input.from,
89
+ to: input.to,
90
+ q: input.q,
91
+ limit: input.limit,
92
+ });
93
+ if (rows.length === 0)
94
+ return "No matching journal lines.";
95
+ return rows
96
+ .map(r => {
97
+ const dr = r.debit > 0 ? `DR ${formatTHB(r.debit)}` : "";
98
+ const cr = r.credit > 0 ? `CR ${formatTHB(r.credit)}` : "";
99
+ return `${r.entry_date} | ${sanitizeForPromptCell(r.entry_description)} | ${sanitizeForPromptCell(r.account_name || "")} | ${dr}${cr} | ${sanitizeForPromptCell(r.memo || "")}`;
100
+ })
101
+ .join("\n");
102
+ }
103
+ case "search_transactions": {
104
+ const rows = searchJournalLines(db, input.query, input.limit);
105
+ if (rows.length === 0)
106
+ return `No matches for "${sanitizeForPrompt(input.query)}".`;
107
+ return rows
108
+ .map(r => {
109
+ const dr = r.debit > 0 ? `DR ${formatTHB(r.debit)}` : "";
110
+ const cr = r.credit > 0 ? `CR ${formatTHB(r.credit)}` : "";
111
+ return `${r.entry_date} | ${sanitizeForPromptCell(r.entry_description)} | ${sanitizeForPromptCell(r.account_name || "")} | ${dr}${cr}`;
112
+ })
113
+ .join("\n");
114
+ }
115
+ case "get_period_totals": {
116
+ const totals = getPeriodTotals(db, input.from, input.to);
117
+ return `Income ${formatTHB(totals.income)} · Expenses ${formatTHB(totals.expenses)} · Net ${formatTHB(totals.income - totals.expenses)}`;
118
+ }
119
+ default:
120
+ return undefined;
121
+ }
122
+ }
123
+ export const readTools = { DEFS, LABELS, execute };
@@ -0,0 +1,2 @@
1
+ import type { ToolModule } from "./types.js";
2
+ export declare const reconcileTools: ToolModule;
@@ -0,0 +1,227 @@
1
+ import { deleteAccount, findSimilarAccounts, findUnusedAccounts, mergeAccounts, renameAccount, } from "../../db/queries/account_balance.js";
2
+ import { deleteJournalEntry, findDuplicateEntries, updateJournalEntry, updateJournalLine, } from "../../db/queries/journal.js";
3
+ import { formatCurrencyAmount } from "../../currency.js";
4
+ import { sanitizeForPrompt } from "../sanitize.js";
5
+ function formatTHB(amount) {
6
+ return formatCurrencyAmount(amount, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
7
+ }
8
+ const DEFS = [
9
+ {
10
+ name: "update_journal_entry",
11
+ description: "Header-only update: date, description, or source_page. To change amounts, delete the entry and record a new one.",
12
+ input_schema: {
13
+ type: "object",
14
+ properties: {
15
+ entry_id: { type: "string" },
16
+ date: { type: "string" },
17
+ description: { type: "string" },
18
+ source_page: { type: "number" },
19
+ },
20
+ required: ["entry_id"],
21
+ },
22
+ },
23
+ {
24
+ name: "update_journal_line",
25
+ description: "Safe single-line edit: re-categorize (account_id) or update memo. Refuses changes to debit/credit/currency — delete and re-record the entry for those.",
26
+ input_schema: {
27
+ type: "object",
28
+ properties: {
29
+ line_id: { type: "string" },
30
+ account_id: { type: "string" },
31
+ memo: { type: "string" },
32
+ },
33
+ required: ["line_id"],
34
+ },
35
+ },
36
+ {
37
+ name: "delete_journal_entry",
38
+ description: "Delete an entry and (via cascade) all its lines. The primitive for removing duplicates.",
39
+ input_schema: {
40
+ type: "object",
41
+ properties: { entry_id: { type: "string" } },
42
+ required: ["entry_id"],
43
+ },
44
+ },
45
+ {
46
+ name: "rename_account",
47
+ description: "Rename an account. Leaves lines and metadata untouched.",
48
+ input_schema: {
49
+ type: "object",
50
+ properties: { account_id: { type: "string" }, name: { type: "string" } },
51
+ required: ["account_id", "name"],
52
+ },
53
+ },
54
+ {
55
+ name: "merge_accounts",
56
+ description: "Move every journal line on `from_id` over to `to_id`, then delete the source account. Use to collapse duplicate accounts.",
57
+ input_schema: {
58
+ type: "object",
59
+ properties: { from_id: { type: "string" }, to_id: { type: "string" } },
60
+ required: ["from_id", "to_id"],
61
+ },
62
+ },
63
+ {
64
+ name: "delete_account",
65
+ description: "Delete an account that has no journal lines. Refuses if any line still references it — merge first.",
66
+ input_schema: {
67
+ type: "object",
68
+ properties: { account_id: { type: "string" } },
69
+ required: ["account_id"],
70
+ },
71
+ },
72
+ {
73
+ name: "find_duplicate_entries",
74
+ description: "Heuristic: groups journal entries by total amount and a configurable date tolerance. Returns groups with two or more candidate dupes.",
75
+ input_schema: {
76
+ type: "object",
77
+ properties: {
78
+ tolerance_days: { type: "number", default: 2 },
79
+ account_id: { type: "string" },
80
+ min_amount: { type: "number" },
81
+ },
82
+ required: [],
83
+ },
84
+ },
85
+ {
86
+ name: "find_similar_accounts",
87
+ description: "Pairwise Levenshtein similarity on account names. Returns pairs above the threshold, sorted highest first.",
88
+ input_schema: {
89
+ type: "object",
90
+ properties: { threshold: { type: "number", default: 0.85 } },
91
+ required: [],
92
+ },
93
+ },
94
+ {
95
+ name: "find_unused_accounts",
96
+ description: "Accounts with zero linked journal lines.",
97
+ input_schema: { type: "object", properties: {}, required: [] },
98
+ },
99
+ {
100
+ name: "mark_reconcile_done",
101
+ description: "Call when reconciliation is complete. The summary is shown to the user.",
102
+ input_schema: {
103
+ type: "object",
104
+ properties: { summary: { type: "string" } },
105
+ required: ["summary"],
106
+ },
107
+ },
108
+ ];
109
+ const LABELS = {
110
+ update_journal_entry: "Updating journal entry",
111
+ update_journal_line: "Updating journal line",
112
+ delete_journal_entry: "Deleting journal entry",
113
+ rename_account: "Renaming account",
114
+ merge_accounts: "Merging accounts",
115
+ delete_account: "Deleting account",
116
+ find_duplicate_entries: "Finding duplicate entries",
117
+ find_similar_accounts: "Finding similar accounts",
118
+ find_unused_accounts: "Finding unused accounts",
119
+ mark_reconcile_done: "Finalizing reconcile",
120
+ };
121
+ async function execute(db, name, input, ctx) {
122
+ switch (name) {
123
+ case "update_journal_entry": {
124
+ if (ctx?.dryRun)
125
+ return `Would update entry ${input.entry_id}: ${JSON.stringify(input)}`;
126
+ const changed = updateJournalEntry(db, input.entry_id, {
127
+ date: input.date,
128
+ description: input.description,
129
+ source_page: input.source_page,
130
+ });
131
+ return changed === 0
132
+ ? `Entry ${input.entry_id} not found or no fields to update.`
133
+ : `Updated entry ${input.entry_id}.`;
134
+ }
135
+ case "update_journal_line": {
136
+ if (ctx?.dryRun)
137
+ return `Would update line ${input.line_id}: ${JSON.stringify(input)}`;
138
+ const changed = updateJournalLine(db, input.line_id, {
139
+ account_id: input.account_id,
140
+ memo: input.memo,
141
+ });
142
+ return changed === 0
143
+ ? `Line ${input.line_id} not found or no fields to update.`
144
+ : `Updated line ${input.line_id}.`;
145
+ }
146
+ case "delete_journal_entry": {
147
+ if (ctx?.dryRun)
148
+ return `Would delete entry ${input.entry_id} (and its lines).`;
149
+ const changed = deleteJournalEntry(db, input.entry_id);
150
+ return changed === 0
151
+ ? `Entry ${input.entry_id} not found.`
152
+ : `Deleted entry ${input.entry_id} and its lines.`;
153
+ }
154
+ case "rename_account": {
155
+ if (ctx?.dryRun)
156
+ return `Would rename ${input.account_id} → "${input.name}".`;
157
+ const changed = renameAccount(db, input.account_id, input.name);
158
+ return changed === 0
159
+ ? `Account ${input.account_id} not found.`
160
+ : `Renamed ${input.account_id} → "${sanitizeForPrompt(input.name)}".`;
161
+ }
162
+ case "merge_accounts": {
163
+ if (ctx?.dryRun)
164
+ return `Would merge ${input.from_id} → ${input.to_id}.`;
165
+ try {
166
+ const moved = mergeAccounts(db, input.from_id, input.to_id);
167
+ return `Merged ${input.from_id} → ${input.to_id}; moved ${moved} line(s).`;
168
+ }
169
+ catch (err) {
170
+ return `Could not merge: ${err.message}`;
171
+ }
172
+ }
173
+ case "delete_account": {
174
+ if (ctx?.dryRun)
175
+ return `Would delete account ${input.account_id}.`;
176
+ try {
177
+ deleteAccount(db, input.account_id);
178
+ return `Deleted account ${input.account_id}.`;
179
+ }
180
+ catch (err) {
181
+ return `Could not delete: ${err.message}`;
182
+ }
183
+ }
184
+ case "find_duplicate_entries": {
185
+ const groups = findDuplicateEntries(db, {
186
+ toleranceDays: input.tolerance_days,
187
+ accountId: input.account_id,
188
+ minAmount: input.min_amount,
189
+ });
190
+ if (groups.length === 0)
191
+ return "No candidate duplicate groups found.";
192
+ return groups
193
+ .map((g, i) => {
194
+ const header = `Group ${i + 1} — ${formatTHB(g[0].amount)}`;
195
+ const lines = g.map((e, j) => {
196
+ const accounts = e.account_names.length > 0
197
+ ? e.account_names.map(n => sanitizeForPrompt(n)).join(", ")
198
+ : "(no lines)";
199
+ return ` ${j + 1}. ${e.date} "${sanitizeForPrompt(e.description)}" — ${accounts} [${e.id}]`;
200
+ });
201
+ return `${header}\n${lines.join("\n")}`;
202
+ })
203
+ .join("\n\n");
204
+ }
205
+ case "find_similar_accounts": {
206
+ const pairs = findSimilarAccounts(db, input.threshold);
207
+ if (pairs.length === 0)
208
+ return "No similar account pairs above threshold.";
209
+ return pairs
210
+ .map(p => `${p.similarity}: ${p.a.id} (${sanitizeForPrompt(p.a.name)}) <-> ${p.b.id} (${sanitizeForPrompt(p.b.name)})`)
211
+ .join("\n");
212
+ }
213
+ case "find_unused_accounts": {
214
+ const rows = findUnusedAccounts(db);
215
+ if (rows.length === 0)
216
+ return "No unused accounts.";
217
+ return rows.map(a => `${a.id} | ${sanitizeForPrompt(a.name)} | ${a.type}`).join("\n");
218
+ }
219
+ case "mark_reconcile_done": {
220
+ ctx?.onComplete?.(input.summary || "");
221
+ return `Reconcile complete. Summary: ${sanitizeForPrompt(input.summary || "")}`;
222
+ }
223
+ default:
224
+ return undefined;
225
+ }
226
+ }
227
+ export const reconcileTools = { DEFS, LABELS, execute };
@@ -0,0 +1,2 @@
1
+ import type { ToolModule } from "./types.js";
2
+ export declare const scanTools: ToolModule;
@@ -0,0 +1,24 @@
1
+ import { sanitizeForPrompt } from "../sanitize.js";
2
+ const DEFS = [
3
+ {
4
+ name: "mark_file_scanned",
5
+ description: "Call this once the file is fully processed and all journal entries are posted. Summary text is shown to the user.",
6
+ input_schema: {
7
+ type: "object",
8
+ properties: {
9
+ summary: { type: "string", description: "Short summary of what was recorded." },
10
+ },
11
+ required: ["summary"],
12
+ },
13
+ },
14
+ ];
15
+ const LABELS = {
16
+ mark_file_scanned: "Finalizing file",
17
+ };
18
+ async function execute(_db, name, input, ctx) {
19
+ if (name !== "mark_file_scanned")
20
+ return undefined;
21
+ ctx?.onComplete?.(input.summary || "");
22
+ return `Marked file as scanned. Summary: ${sanitizeForPrompt(input.summary || "")}`;
23
+ }
24
+ export const scanTools = { DEFS, LABELS, execute };
@@ -0,0 +1,26 @@
1
+ import type Database from "libsql";
2
+ import type { ToolDefinition } from "../provider.js";
3
+ export type ToolProfile = "scan" | "chat" | "reconcile";
4
+ export interface AgentExecutionContext {
5
+ /** Set during scan so `record_journal_entry` can stamp `source_file_id`. */
6
+ fileId?: string;
7
+ /** When false, ask_user returns a marker and the caller halts after the run. */
8
+ interactive: boolean;
9
+ /** When true, mutating tools become no-ops that return a "would do X" preview. */
10
+ dryRun?: boolean;
11
+ /** Synchronously prompt the user (only invoked when interactive === true). */
12
+ promptUser?: (prompt: string, options?: string[]) => Promise<string>;
13
+ /** Called when the model declares the session is done (scan or reconcile). */
14
+ onComplete?: (summary: string) => void;
15
+ }
16
+ /**
17
+ * A tool module owns a slice of tool definitions, the spinner labels that go
18
+ * with them, and an executor that returns `undefined` when the tool name isn't
19
+ * one of its own. Composing modules at the dispatcher layer is just iteration.
20
+ */
21
+ export interface ToolModule {
22
+ readonly DEFS: ToolDefinition[];
23
+ readonly LABELS: Record<string, string>;
24
+ execute(db: Database.Database, name: string, input: any, ctx: AgentExecutionContext | undefined): Promise<string | undefined>;
25
+ }
26
+ export type { ToolDefinition };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ import type Database from "libsql";
2
+ import type { ToolDefinition } from "./provider.js";
3
+ export type ToolProfile = "scan" | "chat";
4
+ export declare function getToolDefinitions(profile: ToolProfile): ToolDefinition[];
5
+ export interface ScanExecutionContext {
6
+ fileId: string;
7
+ scannerVersion: string;
8
+ /** When false, ask_user returns a marker and the pipeline halts after the run. */
9
+ interactive: boolean;
10
+ /** Synchronously prompt the user (only invoked when interactive === true). */
11
+ promptUser?: (prompt: string, options?: string[]) => Promise<string>;
12
+ /** Called when the model declares the file is done. */
13
+ onMarkScanned?: (summary: string) => void;
14
+ }
15
+ /** Human-readable labels shown in the spinner. */
16
+ export declare const TOOL_LABELS: Record<string, string>;
17
+ export declare function executeTool(db: Database.Database, toolName: string, toolInput: any, ctx?: ScanExecutionContext): Promise<string>;
18
+ export declare function getTaxonomyHint(): string;