plasalid 0.3.4 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -40
- package/dist/accounts/taxonomy.d.ts +1 -1
- package/dist/accounts/taxonomy.js +2 -2
- package/dist/ai/agent.d.ts +6 -5
- package/dist/ai/agent.js +7 -6
- package/dist/ai/memory.d.ts +12 -5
- package/dist/ai/memory.js +12 -0
- package/dist/ai/personas.d.ts +10 -0
- package/dist/ai/personas.js +123 -0
- package/dist/ai/prompt-sections.d.ts +44 -0
- package/dist/ai/prompt-sections.js +89 -0
- package/dist/ai/system-prompt.d.ts +3 -3
- package/dist/ai/system-prompt.js +44 -165
- package/dist/ai/tools/index.js +12 -7
- package/dist/ai/tools/ingest.d.ts +2 -1
- package/dist/ai/tools/ingest.js +220 -83
- package/dist/ai/tools/read.js +31 -0
- package/dist/ai/tools/review.d.ts +2 -0
- package/dist/ai/tools/review.js +362 -0
- package/dist/ai/tools/scan.js +4 -2
- package/dist/ai/tools/types.d.ts +23 -3
- package/dist/cli/commands/review.d.ts +2 -0
- package/dist/cli/commands/review.js +15 -0
- package/dist/cli/commands/scan.d.ts +4 -2
- package/dist/cli/commands/scan.js +147 -19
- package/dist/cli/index.js +11 -8
- package/dist/cli/ink/scan_dashboard.d.ts +38 -0
- package/dist/cli/ink/scan_dashboard.js +62 -0
- package/dist/cli/ux.d.ts +2 -1
- package/dist/cli/ux.js +36 -2
- package/dist/db/queries/account_balance.d.ts +1 -0
- package/dist/db/queries/concerns.d.ts +47 -0
- package/dist/db/queries/concerns.js +87 -0
- package/dist/db/queries/journal.d.ts +74 -8
- package/dist/db/queries/journal.js +131 -19
- package/dist/db/queries/recurrences.d.ts +33 -0
- package/dist/db/queries/recurrences.js +130 -0
- package/dist/db/schema.js +25 -2
- package/dist/reviewer/pipeline.d.ts +18 -0
- package/dist/reviewer/pipeline.js +46 -0
- package/dist/reviewer/prompts.d.ts +12 -0
- package/dist/reviewer/prompts.js +22 -0
- package/dist/scanner/account_mutex.d.ts +1 -0
- package/dist/scanner/account_mutex.js +16 -0
- package/dist/scanner/buffer.d.ts +48 -0
- package/dist/scanner/buffer.js +63 -0
- package/dist/scanner/concurrency.d.ts +14 -0
- package/dist/scanner/concurrency.js +31 -0
- package/dist/scanner/decrypt_queue.d.ts +57 -0
- package/dist/scanner/decrypt_queue.js +96 -0
- package/dist/scanner/pipeline.d.ts +46 -18
- package/dist/scanner/pipeline.js +250 -97
- package/dist/scanner/prompts.js +1 -1
- package/package.json +1 -1
package/dist/ai/system-prompt.js
CHANGED
|
@@ -1,174 +1,53 @@
|
|
|
1
1
|
import { config } from "../config.js";
|
|
2
|
-
import { getMemories } from "./memory.js";
|
|
3
2
|
import { readContext } from "./context.js";
|
|
4
|
-
import {
|
|
5
|
-
import { getAccountBalances } from "../db/queries/account_balance.js";
|
|
3
|
+
import { chatPersona, SCAN_PERSONA, REVIEW_PERSONA } from "./personas.js";
|
|
6
4
|
import { getThaiTaxonomyHint } from "../accounts/taxonomy.js";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
- Always cite actual figures, dates, and account names from tool results. Never make up data.
|
|
13
|
-
- Have a point of view. When ${name} asks "what should I do?", say what you'd do and why. Present alternatives only after your recommendation.
|
|
14
|
-
- Be proactive: if you notice something concerning (overdraft trajectory, unusual spending, a payment due soon, a balance trending the wrong way), bring it up even if not asked.
|
|
15
|
-
- Be concise. 2-4 sentences for simple questions. Skip preamble like "Great question!" or "Let me look that up." Just answer.
|
|
16
|
-
- Warm but direct. Celebrate wins genuinely. Flag problems without sugarcoating.
|
|
17
|
-
|
|
18
|
-
## How you work
|
|
19
|
-
1. Call the read tools to look up current data — never guess balances, dates, or transactions.
|
|
20
|
-
2. Connect the dots. Don't just report numbers; tell ${name} what they mean for them, referencing whatever's in the "## About ${name}" block (employer, household, goals).
|
|
21
|
-
3. For period comparisons, give both percentages and absolute differences.
|
|
22
|
-
4. End with a next step when it helps. A good partner always has a suggestion.
|
|
23
|
-
5. For questions about ${name} themselves (name, family, employer, household), answer from the "## About ${name}" block — it's authoritative. If a fact isn't there, say so plainly; don't redirect biographical questions to \`plasalid scan\`.
|
|
24
|
-
6. Default currency is THB unless an account is explicitly in another. Don't mix currencies.
|
|
25
|
-
|
|
26
|
-
## Output rules
|
|
27
|
-
- Reply in the dominant language of ${name}'s message; default to English when mixed or ambiguous.
|
|
28
|
-
- Markdown sparingly: **bold** for figures, simple bullets, no code blocks.
|
|
29
|
-
- No emoji of any kind (no check marks, crosses, warning signs, colored circles, faces, hands, arrows-as-emoji). Use plain words.
|
|
30
|
-
- No tables — no markdown \`|\` tables, no ASCII grids, no pipe-delimited rows. The TUI breaks them. Use prose, dashes, or numbered lists.
|
|
31
|
-
- If the data needed to answer isn't in the database, say so plainly and suggest \`plasalid scan\` when relevant.`;
|
|
32
|
-
}
|
|
33
|
-
const SCAN_PERSONA = `You are Plasalid's scanner. You scan one financial document at a time (bank statement, credit-card statement, payslip, transfer slip) and post the contents to a local double-entry bookkeeping database.
|
|
34
|
-
|
|
35
|
-
Rules:
|
|
36
|
-
1. Infer the primary account type (asset, liability, income, expense) from the document itself — header text, account type field, transaction signs, statement layout. Do not rely on the filename or directory.
|
|
37
|
-
2. Every transaction must become a balanced \`record_journal_entry\` call. Total debits must equal total credits.
|
|
38
|
-
3. Account-type conventions:
|
|
39
|
-
- **Asset** (e.g. bank, cash): DEBIT increases, CREDIT decreases.
|
|
40
|
-
- **Liability** (e.g. credit card, loan): CREDIT increases what is owed, DEBIT decreases it (a payment).
|
|
41
|
-
- **Income**: CREDIT increases.
|
|
42
|
-
- **Expense**: DEBIT increases.
|
|
43
|
-
4. Dates: convert Buddhist Era → Gregorian by subtracting 543 from the year. Store as YYYY-MM-DD.
|
|
44
|
-
5. Default currency is THB. Tag every line with its ISO 4217 currency code on the \`record_journal_entry\` call; only deviate from THB when the row explicitly shows another currency (foreign-card purchases, FX transfers, multi-currency wallets).
|
|
45
|
-
6. Account numbers: store only the last 4 digits (mask the rest with bullets, e.g. \`••1234\`). Never persist the full account number.
|
|
46
|
-
7. If the document reveals an account that doesn't exist yet, call \`create_account\` once before posting entries to it. Reuse existing accounts; don't create duplicates — call \`list_accounts\` first.
|
|
47
|
-
8. Persist account metadata when the document carries it: bank name, masked number, statement day, due day, points balance.
|
|
48
|
-
9. If you are unsure about a row (ambiguous category, missing date, unclear sign), call \`ask_user\` instead of guessing.
|
|
49
|
-
10. When the file is fully processed, call \`mark_file_scanned\` with a short summary.
|
|
50
|
-
|
|
51
|
-
Common Thai statement patterns to expect:
|
|
52
|
-
- Bank statements list incoming, outgoing with running balance.
|
|
53
|
-
- Credit-card statements list a statement balance, minimum payment, due date, statement-cut date, and per-transaction rows.
|
|
54
|
-
- Payslips list gross salary, tax, social-security, and net pay.
|
|
55
|
-
- Transfer slips (PromptPay / mobile banking) show source account, destination account, amount, and a reference number.
|
|
56
|
-
|
|
57
|
-
Pick a stable account id format: \`<type>:<bank>-<subtype>-<last4>\`, e.g. \`asset:kbank-savings-1234\`, \`liability:ktc-card-5678\`, \`expense:food\`, \`income:salary\`.
|
|
58
|
-
|
|
59
|
-
How to phrase ask_user when you're unsure about a row:
|
|
60
|
-
- Frame each question as a complete sentence with enough context: include the date, the amount (formatted as ฿N,NNN.NN), and the row's description.
|
|
61
|
-
- Never reference accounts by their internal id (\`a:…\`). Use the human account name (e.g. "KBank Savings ••8745") in the question.
|
|
62
|
-
- Always include "Skip — leave as is" as one of the options.
|
|
63
|
-
|
|
64
|
-
Output formatting: use plain ASCII numbers (\`1.\`, \`2.\`, \`3.\`) for any lists. Never use Unicode circled digits (①②③). Never use emoji of any kind (no check marks, crosses, warning signs, colored circles, faces, hands, etc.) — use plain words.`;
|
|
5
|
+
import { renderChartOfAccounts, renderChatChartOrEmpty, renderMemories, renderScope, renderTodayHuman, renderTodayIso, renderUserContext, } from "./prompt-sections.js";
|
|
6
|
+
// ── Builders ────────────────────────────────────────────────────────────────
|
|
7
|
+
// Each builder is a list of sections in render order. No accumulation, no
|
|
8
|
+
// inline string assembly. To edit a section, change the helper; to reorder,
|
|
9
|
+
// shuffle the array.
|
|
65
10
|
export function buildChatSystemPrompt(db) {
|
|
66
|
-
const memories = getMemories(db);
|
|
67
|
-
const context = readContext();
|
|
68
11
|
const name = config.userName;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
? context
|
|
80
|
-
: `(No personal context on file yet. ${name} can edit ~/.plasalid/context.md to add family, income, or other facts.)`;
|
|
81
|
-
const balances = getAccountBalances(db);
|
|
82
|
-
if (balances.length > 0) {
|
|
83
|
-
prompt += `\n\n## Accounts on file\n`;
|
|
84
|
-
prompt += balances
|
|
85
|
-
.map((a) => `- ${a.id} | ${a.name} | ${a.type}${a.subtype ? `/${a.subtype}` : ""} | balance ${a.balance.toFixed(2)} ${a.currency}`)
|
|
86
|
-
.join("\n");
|
|
87
|
-
}
|
|
88
|
-
else {
|
|
89
|
-
prompt += `\n\nNo accounts have been scanned yet. ${name} should drop files into ~/.plasalid/data/ and run \`plasalid scan\`.`;
|
|
90
|
-
}
|
|
91
|
-
if (memories.length > 0) {
|
|
92
|
-
prompt += `\n\n## Things to remember about ${name}\n`;
|
|
93
|
-
prompt += memories
|
|
94
|
-
.map((m) => `- [${m.category}] ${stripControls(m.content)}`)
|
|
95
|
-
.join("\n");
|
|
96
|
-
}
|
|
97
|
-
return prompt;
|
|
12
|
+
return joinSections([
|
|
13
|
+
chatPersona(name),
|
|
14
|
+
renderTodayHuman(),
|
|
15
|
+
renderUserContext(name, readContext()),
|
|
16
|
+
renderChatChartOrEmpty(db, name),
|
|
17
|
+
renderMemories(db, {
|
|
18
|
+
header: `Things to remember about ${name}`,
|
|
19
|
+
showCategory: true,
|
|
20
|
+
}),
|
|
21
|
+
]);
|
|
98
22
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
- Never reference entries or accounts by their internal id (\`je:…\`, \`a:…\`) in the question. Use the date + description + human account name instead.
|
|
111
|
-
- Always include "Skip — leave as is" as one of the options so the user has an explicit do-nothing path.
|
|
112
|
-
- Example:
|
|
113
|
-
Avoid: \`prompt: "Merge these?"\`
|
|
114
|
-
Prefer: \`prompt: "Two ฿350 lunch entries dated 2026-01-15 and 2026-01-17, both posted to KBank Savings ••8745 → Food. Merge into one?"\`, \`options: ["Yes — delete the 01-17 entry", "Yes — delete the 01-15 entry", "Skip — leave as is"]\`
|
|
115
|
-
|
|
116
|
-
How to write the mark_reconcile_done summary:
|
|
117
|
-
- Write a short user-facing report of what actually changed, in plain language. The user does not know what the internal detectors are.
|
|
118
|
-
- Report counts and actions, never internal grouping. Examples:
|
|
119
|
-
- "Merged 3 duplicate transactions. Renamed 1 account. No other changes."
|
|
120
|
-
- "No duplicates found. Skipped 2 unused accounts on your instruction."
|
|
121
|
-
- Never reference internal detector names (\`find_duplicate_entries\`, "Group 1", "Group 1-5", "candidate group N"). Those are tool internals.
|
|
122
|
-
- Keep the summary to one to three sentences. Lead with what was applied; tail with what was skipped or deferred.
|
|
123
|
-
|
|
124
|
-
Output formatting:
|
|
125
|
-
- Use plain ASCII numbers (\`1.\`, \`2.\`, \`3.\`) for any lists or option numbering you generate. Never use Unicode circled digits (①②③).
|
|
126
|
-
- Never use emoji of any kind (no check marks, crosses, warning signs, colored circles, faces, hands, etc.) — use plain words.
|
|
127
|
-
- Always reply in English.
|
|
128
|
-
- Be brief in prose; the user is reviewing in real time and wants to confirm fast.`;
|
|
129
|
-
export function buildReconcileSystemPrompt(db, opts) {
|
|
130
|
-
const balances = getAccountBalances(db);
|
|
131
|
-
const memories = getMemories(db);
|
|
132
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
133
|
-
let prompt = `${RECONCILE_PERSONA}\n\nToday is ${today}.\n\n## Current chart of accounts\n`;
|
|
134
|
-
prompt +=
|
|
135
|
-
balances.length === 0
|
|
136
|
-
? "(empty)"
|
|
137
|
-
: balances
|
|
138
|
-
.map((a) => `- ${a.id} | ${a.name} | ${a.type}${a.subtype ? `/${a.subtype}` : ""} | balance ${a.balance.toFixed(2)} ${a.currency}`)
|
|
139
|
-
.join("\n");
|
|
140
|
-
prompt += `\n\n## Scope`;
|
|
141
|
-
prompt += `\n- account: ${opts.accountId ?? "all"}`;
|
|
142
|
-
prompt += `\n- from: ${opts.from ?? "all time"}`;
|
|
143
|
-
prompt += `\n- to: ${opts.to ?? "now"}`;
|
|
144
|
-
prompt += `\n- dry run: ${opts.dryRun ? "yes — write tools will not mutate the DB" : "no — write tools will mutate the DB after confirmation"}`;
|
|
145
|
-
if (memories.length > 0) {
|
|
146
|
-
prompt += `\n\n## Saved memories (apply where relevant)\n`;
|
|
147
|
-
prompt += memories
|
|
148
|
-
.map((m) => `- [${m.category}] ${stripControls(m.content)}`)
|
|
149
|
-
.join("\n");
|
|
150
|
-
}
|
|
151
|
-
return prompt;
|
|
23
|
+
export function buildReviewSystemPrompt(db, opts) {
|
|
24
|
+
return joinSections([
|
|
25
|
+
REVIEW_PERSONA,
|
|
26
|
+
renderTodayIso(),
|
|
27
|
+
renderChartOfAccounts(db, { withBalance: true, emptyState: "review" }),
|
|
28
|
+
renderScope(opts),
|
|
29
|
+
renderMemories(db, {
|
|
30
|
+
header: "Rules you've already learned (apply directly; do not re-ask the user)",
|
|
31
|
+
showCategory: true,
|
|
32
|
+
}),
|
|
33
|
+
]);
|
|
152
34
|
}
|
|
153
35
|
export function buildScanSystemPrompt(db, opts) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
.join("\n");
|
|
172
|
-
}
|
|
173
|
-
return prompt;
|
|
36
|
+
return joinSections([
|
|
37
|
+
SCAN_PERSONA,
|
|
38
|
+
renderTodayIso(),
|
|
39
|
+
renderChartOfAccounts(db, { withBalance: false, emptyState: "scan" }),
|
|
40
|
+
`## File context\nFile: ${opts.fileName}`,
|
|
41
|
+
`## Taxonomy hints\n${getThaiTaxonomyHint()}`,
|
|
42
|
+
renderMemories(db, {
|
|
43
|
+
header: "Rules you've already learned (apply silently before raising a concern)",
|
|
44
|
+
filterCategories: ["scanning_hint", "general"],
|
|
45
|
+
showCategory: false,
|
|
46
|
+
}),
|
|
47
|
+
]);
|
|
48
|
+
}
|
|
49
|
+
// ── Composition helper ─────────────────────────────────────────────────────
|
|
50
|
+
/** Drop null/empty sections, join the rest with a blank line. */
|
|
51
|
+
function joinSections(sections) {
|
|
52
|
+
return sections.filter((s) => !!s).join("\n\n");
|
|
174
53
|
}
|
package/dist/ai/tools/index.js
CHANGED
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
import { commonTools } from "./common.js";
|
|
2
2
|
import { readTools } from "./read.js";
|
|
3
|
-
import {
|
|
3
|
+
import { scanIngestTools, reviewIngestTools } from "./ingest.js";
|
|
4
4
|
import { scanTools } from "./scan.js";
|
|
5
|
-
import {
|
|
5
|
+
import { reviewTools } from "./review.js";
|
|
6
6
|
/**
|
|
7
7
|
* Profile composition. Each profile is the union of one or more tool modules;
|
|
8
8
|
* the dispatcher iterates every module on each tool call so we never need a
|
|
9
9
|
* central switch.
|
|
10
|
+
*
|
|
11
|
+
* `scanIngestTools` (create_account / update_account_metadata /
|
|
12
|
+
* record_journal_entry / note_concern) ships with both scan and review — scan
|
|
13
|
+
* uses them to post, review uses them to fix.
|
|
10
14
|
*/
|
|
11
15
|
const PROFILES = {
|
|
12
|
-
scan: [commonTools,
|
|
16
|
+
scan: [commonTools, scanIngestTools, scanTools],
|
|
13
17
|
chat: [commonTools, readTools],
|
|
14
|
-
|
|
18
|
+
review: [commonTools, readTools, scanIngestTools, reviewIngestTools, reviewTools],
|
|
15
19
|
};
|
|
16
20
|
export function getToolDefinitions(profile) {
|
|
17
21
|
return PROFILES[profile].flatMap(m => m.DEFS);
|
|
18
22
|
}
|
|
19
23
|
export async function executeTool(db, name, input, ctx) {
|
|
20
|
-
for (const mod of [commonTools, readTools,
|
|
24
|
+
for (const mod of [commonTools, readTools, scanIngestTools, reviewIngestTools, scanTools, reviewTools]) {
|
|
21
25
|
const result = await mod.execute(db, name, input, ctx);
|
|
22
26
|
if (result !== undefined)
|
|
23
27
|
return result;
|
|
@@ -28,7 +32,8 @@ export async function executeTool(db, name, input, ctx) {
|
|
|
28
32
|
export const TOOL_LABELS = {
|
|
29
33
|
...commonTools.LABELS,
|
|
30
34
|
...readTools.LABELS,
|
|
31
|
-
...
|
|
35
|
+
...scanIngestTools.LABELS,
|
|
36
|
+
...reviewIngestTools.LABELS,
|
|
32
37
|
...scanTools.LABELS,
|
|
33
|
-
...
|
|
38
|
+
...reviewTools.LABELS,
|
|
34
39
|
};
|
package/dist/ai/tools/ingest.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import { randomUUID } from "crypto";
|
|
2
1
|
import { findAccountById } from "../../db/queries/account_balance.js";
|
|
3
2
|
import { recordJournalEntry } from "../../db/queries/journal.js";
|
|
3
|
+
import { getConcernTarget, recordConcern, resolveConcern, } from "../../db/queries/concerns.js";
|
|
4
|
+
import { runExclusive as runAccountExclusive } from "../../scanner/account_mutex.js";
|
|
4
5
|
import { sanitizeForPrompt } from "../sanitize.js";
|
|
5
|
-
import {
|
|
6
|
+
import { ACCOUNT_TYPE_DESCRIPTIONS, } from "../../accounts/taxonomy.js";
|
|
6
7
|
const ACCOUNT_TYPES = Object.keys(ACCOUNT_TYPE_DESCRIPTIONS);
|
|
7
|
-
|
|
8
|
+
// ── Scan-side tool definitions ──────────────────────────────────────────────
|
|
9
|
+
// These tools are exposed during both `plasalid scan` and `plasalid review`:
|
|
10
|
+
// scan uses them to post the initial picture; review uses the same primitives
|
|
11
|
+
// to fix mistakes (re-create a botched account, post a corrected entry, etc.).
|
|
12
|
+
// `note_concern` belongs here too — it records a clarification without ever
|
|
13
|
+
// prompting the user, which is what scan needs.
|
|
14
|
+
const SCAN_DEFS = [
|
|
8
15
|
{
|
|
9
16
|
name: "create_account",
|
|
10
17
|
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'.",
|
|
@@ -71,29 +78,40 @@ const DEFS = [
|
|
|
71
78
|
},
|
|
72
79
|
},
|
|
73
80
|
{
|
|
74
|
-
name: "
|
|
75
|
-
description: "
|
|
81
|
+
name: "note_concern",
|
|
82
|
+
description: "Record a clarification request without pausing the run. Use during scan when a row is ambiguous (post your best-guess journal entry first, then call this with the entry's id), when a row is unparseable (skip the entry, call this with no entry_id), or when you have a concern about an account itself (pass account_id). The reviewer picks these up later with the full picture.",
|
|
76
83
|
input_schema: {
|
|
77
84
|
type: "object",
|
|
78
85
|
properties: {
|
|
79
|
-
prompt: {
|
|
86
|
+
prompt: {
|
|
87
|
+
type: "string",
|
|
88
|
+
description: "The question or concern in a complete sentence, with date, ฿-formatted amount, and human account names. Never reference internal ids.",
|
|
89
|
+
},
|
|
80
90
|
options: {
|
|
81
91
|
type: "array",
|
|
82
|
-
description: "Optional list of candidate answers.",
|
|
92
|
+
description: "Optional list of candidate answers the reviewer can offer the user.",
|
|
83
93
|
items: { type: "string" },
|
|
84
94
|
},
|
|
95
|
+
entry_id: {
|
|
96
|
+
type: "string",
|
|
97
|
+
description: "Id of the journal entry this concern relates to (returned by record_journal_entry). Omit for file-level concerns about an unparseable row.",
|
|
98
|
+
},
|
|
99
|
+
account_id: {
|
|
100
|
+
type: "string",
|
|
101
|
+
description: "Id of the account this concern relates to. Set when the statement's bank name, currency, statement_day, due_day, or other metadata disagrees with the stored account, or when you suspect a new account you're about to create duplicates an existing one. Can be combined with entry_id.",
|
|
102
|
+
},
|
|
85
103
|
},
|
|
86
104
|
required: ["prompt"],
|
|
87
105
|
},
|
|
88
106
|
},
|
|
89
107
|
];
|
|
90
|
-
const
|
|
108
|
+
const SCAN_LABELS = {
|
|
91
109
|
create_account: "Creating account",
|
|
92
110
|
update_account_metadata: "Updating account metadata",
|
|
93
111
|
record_journal_entry: "Posting journal entry",
|
|
94
|
-
|
|
112
|
+
note_concern: "Noting concern",
|
|
95
113
|
};
|
|
96
|
-
async function
|
|
114
|
+
async function scanExecute(db, name, input, ctx) {
|
|
97
115
|
switch (name) {
|
|
98
116
|
case "create_account": {
|
|
99
117
|
if (ctx?.dryRun)
|
|
@@ -101,102 +119,221 @@ async function execute(db, name, input, ctx) {
|
|
|
101
119
|
if (!ACCOUNT_TYPES.includes(input.type)) {
|
|
102
120
|
return `Invalid type "${input.type}". Allowed: ${ACCOUNT_TYPES.join(", ")}.`;
|
|
103
121
|
}
|
|
104
|
-
const knownCodes = new Set(ALL_THAI_INSTITUTIONS.map(i => i.code));
|
|
105
122
|
const bank = input.bank_name ? String(input.bank_name).toUpperCase() : null;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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.`;
|
|
123
|
+
// Account writes serialize across concurrent scan agents so the next
|
|
124
|
+
// list_accounts call (from any agent) sees this row.
|
|
125
|
+
return await runAccountExclusive(() => {
|
|
126
|
+
try {
|
|
127
|
+
db.prepare(`INSERT INTO accounts (id, name, type, subtype, bank_name, account_number_masked, currency, due_day, statement_day, metadata_json)
|
|
128
|
+
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);
|
|
129
|
+
return `Account created: ${input.id} (${input.name}, ${input.type}).`;
|
|
117
130
|
}
|
|
118
|
-
|
|
119
|
-
|
|
131
|
+
catch (err) {
|
|
132
|
+
if (String(err.message).includes("UNIQUE")) {
|
|
133
|
+
return `Account "${input.id}" already exists. Use update_account_metadata to modify it.`;
|
|
134
|
+
}
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
120
138
|
}
|
|
121
139
|
case "update_account_metadata": {
|
|
122
140
|
if (ctx?.dryRun)
|
|
123
141
|
return `Would update metadata for ${input.account_id}: ${JSON.stringify(input)}`;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
142
|
+
return await runAccountExclusive(() => {
|
|
143
|
+
const acct = findAccountById(db, input.account_id);
|
|
144
|
+
if (!acct)
|
|
145
|
+
return `Account "${input.account_id}" not found.`;
|
|
146
|
+
const updates = [];
|
|
147
|
+
const params = [];
|
|
148
|
+
if (input.due_day !== undefined) {
|
|
149
|
+
updates.push("due_day = ?");
|
|
150
|
+
params.push(input.due_day);
|
|
151
|
+
}
|
|
152
|
+
if (input.statement_day !== undefined) {
|
|
153
|
+
updates.push("statement_day = ?");
|
|
154
|
+
params.push(input.statement_day);
|
|
155
|
+
}
|
|
156
|
+
if (input.points_balance !== undefined) {
|
|
157
|
+
updates.push("points_balance = ?");
|
|
158
|
+
params.push(input.points_balance);
|
|
159
|
+
}
|
|
160
|
+
if (input.account_number_masked !== undefined) {
|
|
161
|
+
updates.push("account_number_masked = ?");
|
|
162
|
+
params.push(input.account_number_masked);
|
|
163
|
+
}
|
|
164
|
+
if (input.bank_name !== undefined) {
|
|
165
|
+
updates.push("bank_name = ?");
|
|
166
|
+
params.push(String(input.bank_name).toUpperCase());
|
|
167
|
+
}
|
|
168
|
+
if (input.metadata) {
|
|
169
|
+
const existing = acct.metadata_json ? JSON.parse(acct.metadata_json) : {};
|
|
170
|
+
const merged = { ...existing, ...input.metadata };
|
|
171
|
+
updates.push("metadata_json = ?");
|
|
172
|
+
params.push(JSON.stringify(merged));
|
|
173
|
+
}
|
|
174
|
+
if (updates.length === 0)
|
|
175
|
+
return "Nothing to update.";
|
|
176
|
+
params.push(input.account_id);
|
|
177
|
+
db.prepare(`UPDATE accounts SET ${updates.join(", ")} WHERE id = ?`).run(...params);
|
|
178
|
+
return `Updated ${input.account_id}.`;
|
|
179
|
+
});
|
|
160
180
|
}
|
|
161
181
|
case "record_journal_entry": {
|
|
162
182
|
if (!ctx)
|
|
163
183
|
return "record_journal_entry is only available inside an agent session.";
|
|
164
184
|
if (ctx.dryRun)
|
|
165
185
|
return `Would post journal entry "${input.description}" on ${input.date}.`;
|
|
186
|
+
const entryInput = {
|
|
187
|
+
date: input.date,
|
|
188
|
+
description: input.description,
|
|
189
|
+
source_file_id: ctx.fileId,
|
|
190
|
+
source_page: input.source_page ?? null,
|
|
191
|
+
lines: (input.lines || []).map((l) => ({
|
|
192
|
+
account_id: l.account_id,
|
|
193
|
+
debit: l.debit ?? 0,
|
|
194
|
+
credit: l.credit ?? 0,
|
|
195
|
+
currency: l.currency || "THB",
|
|
196
|
+
memo: l.memo ?? null,
|
|
197
|
+
})),
|
|
198
|
+
};
|
|
166
199
|
try {
|
|
167
|
-
const entryId =
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
});
|
|
200
|
+
const entryId = ctx.buffer
|
|
201
|
+
? ctx.buffer.appendEntry(entryInput)
|
|
202
|
+
: recordJournalEntry(db, entryInput);
|
|
180
203
|
return `Posted journal entry ${entryId} (${input.date}).`;
|
|
181
204
|
}
|
|
182
205
|
catch (err) {
|
|
183
206
|
return `Could not post journal entry: ${err.message}`;
|
|
184
207
|
}
|
|
185
208
|
}
|
|
186
|
-
case "
|
|
209
|
+
case "note_concern": {
|
|
187
210
|
if (!ctx)
|
|
188
|
-
return "
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
211
|
+
return "note_concern is only available inside an agent session.";
|
|
212
|
+
const target = {
|
|
213
|
+
entry_id: input.entry_id ?? null,
|
|
214
|
+
account_id: input.account_id ?? null,
|
|
215
|
+
};
|
|
216
|
+
if (ctx.buffer) {
|
|
217
|
+
ctx.buffer.appendConcern({ ...target, prompt: input.prompt, options: input.options });
|
|
218
|
+
return `Concern noted (buffered). Continue with the next row.`;
|
|
195
219
|
}
|
|
196
|
-
|
|
220
|
+
const id = recordConcern(db, {
|
|
221
|
+
file_id: ctx.fileId ?? null,
|
|
222
|
+
entry_id: target.entry_id,
|
|
223
|
+
account_id: target.account_id,
|
|
224
|
+
prompt: input.prompt,
|
|
225
|
+
options: input.options,
|
|
226
|
+
});
|
|
227
|
+
return `Concern noted (${id}). Continue with the next row.`;
|
|
197
228
|
}
|
|
198
229
|
default:
|
|
199
230
|
return undefined;
|
|
200
231
|
}
|
|
201
232
|
}
|
|
202
|
-
export const
|
|
233
|
+
export const scanIngestTools = {
|
|
234
|
+
DEFS: SCAN_DEFS,
|
|
235
|
+
LABELS: SCAN_LABELS,
|
|
236
|
+
execute: scanExecute,
|
|
237
|
+
};
|
|
238
|
+
// ── Review-only tool definitions ────────────────────────────────────────────
|
|
239
|
+
// `ask_user` is the only interactive primitive. Scan never reaches it (the
|
|
240
|
+
// scan profile doesn't include this module), so we don't need a "scan, please
|
|
241
|
+
// don't use this" guard.
|
|
242
|
+
const REVIEW_DEFS = [
|
|
243
|
+
{
|
|
244
|
+
name: "ask_user",
|
|
245
|
+
description: "Ask the user a clarifying question when you cannot confidently proceed. The pipeline pauses and prompts the user interactively. Available during `plasalid review`. Not exposed during `plasalid scan` — use `note_concern` instead. Pass `entry_id` / `account_id` to attach the question to the same target as a scan-noted concern. Pass `concern_id` to resolve an existing open concern in place (recommended when re-posing a scan-noted concern to the user). Pass `related_concern_ids` to apply the user's single answer to a whole group of sibling concerns at once.",
|
|
246
|
+
input_schema: {
|
|
247
|
+
type: "object",
|
|
248
|
+
properties: {
|
|
249
|
+
prompt: { type: "string", description: "The question to ask in plain language." },
|
|
250
|
+
options: {
|
|
251
|
+
type: "array",
|
|
252
|
+
description: "Optional list of candidate answers.",
|
|
253
|
+
items: { type: "string" },
|
|
254
|
+
},
|
|
255
|
+
entry_id: {
|
|
256
|
+
type: "string",
|
|
257
|
+
description: "Optional: journal entry this question is about. Used to clear the entry's has_concern flag once all its concerns close.",
|
|
258
|
+
},
|
|
259
|
+
account_id: {
|
|
260
|
+
type: "string",
|
|
261
|
+
description: "Optional: account this question is about. Used to clear the account's has_concern flag once all its concerns close.",
|
|
262
|
+
},
|
|
263
|
+
concern_id: {
|
|
264
|
+
type: "string",
|
|
265
|
+
description: "Optional: id of an existing open concern. If supplied, the user's answer resolves that row in place instead of creating a new one.",
|
|
266
|
+
},
|
|
267
|
+
related_concern_ids: {
|
|
268
|
+
type: "array",
|
|
269
|
+
items: { type: "string" },
|
|
270
|
+
description: "Optional: ids of additional open concerns that share the same answer as `concern_id`. The user is prompted once; every listed concern (plus the primary) is marked resolved with the same answer. Use this for grouping duplicate questions — e.g., 12 Lazada rows that all categorize the same way — so the user isn't asked the same thing twelve times.",
|
|
271
|
+
},
|
|
272
|
+
facts: {
|
|
273
|
+
type: "object",
|
|
274
|
+
description: "Optional structured highlights rendered as a single colored header line above the question. Provide whichever fields apply; the prompter colorizes each by category (amount=yellow, date=cyan, merchant=green, accounts=magenta). Keep the `prompt` text short — the facts header carries the context.",
|
|
275
|
+
properties: {
|
|
276
|
+
amount: { type: "string", description: "฿-formatted amount, e.g. '฿1,200.00'." },
|
|
277
|
+
date: { type: "string", description: "ISO date or short range, e.g. '2026-04-15' or '2026-02-15 to 2026-05-15'." },
|
|
278
|
+
merchant: { type: "string", description: "Counterparty / merchant name, e.g. 'LAZADA TH', 'Spotify'." },
|
|
279
|
+
accounts: {
|
|
280
|
+
type: "array",
|
|
281
|
+
items: { type: "string" },
|
|
282
|
+
description: "Human account names involved. For merges, list the survivor first.",
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
required: ["prompt"],
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
];
|
|
291
|
+
const REVIEW_LABELS = {
|
|
292
|
+
ask_user: "Asking for clarification",
|
|
293
|
+
};
|
|
294
|
+
async function reviewExecute(db, name, input, ctx) {
|
|
295
|
+
if (name !== "ask_user")
|
|
296
|
+
return undefined;
|
|
297
|
+
if (!ctx)
|
|
298
|
+
return "ask_user is only available inside an agent session.";
|
|
299
|
+
// Two modes: resolve an existing concern in place (concern_id supplied),
|
|
300
|
+
// or post a fresh question that becomes its own concerns row.
|
|
301
|
+
let id;
|
|
302
|
+
if (input.concern_id) {
|
|
303
|
+
id = String(input.concern_id);
|
|
304
|
+
if (!getConcernTarget(db, id))
|
|
305
|
+
return `Concern ${id} not found.`;
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
id = recordConcern(db, {
|
|
309
|
+
file_id: ctx.fileId ?? null,
|
|
310
|
+
entry_id: input.entry_id ?? null,
|
|
311
|
+
account_id: input.account_id ?? null,
|
|
312
|
+
prompt: input.prompt,
|
|
313
|
+
options: input.options,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
if (ctx.interactive && ctx.promptUser) {
|
|
317
|
+
const answer = await ctx.promptUser(input.prompt, input.options, input.facts);
|
|
318
|
+
resolveConcern(db, id, answer);
|
|
319
|
+
// Propagate the same answer to every sibling in the group so the user
|
|
320
|
+
// isn't asked the same thing again. Skip the primary id if the agent
|
|
321
|
+
// happened to include it.
|
|
322
|
+
const siblings = Array.isArray(input.related_concern_ids) ? input.related_concern_ids : [];
|
|
323
|
+
let propagated = 0;
|
|
324
|
+
for (const sibId of siblings) {
|
|
325
|
+
if (sibId === id)
|
|
326
|
+
continue;
|
|
327
|
+
if (resolveConcern(db, String(sibId), answer))
|
|
328
|
+
propagated++;
|
|
329
|
+
}
|
|
330
|
+
const totalResolved = 1 + propagated;
|
|
331
|
+
return `User answered: ${sanitizeForPrompt(answer)}${totalResolved > 1 ? ` (applied to ${totalResolved} concern${totalResolved === 1 ? "" : "s"})` : ""}`;
|
|
332
|
+
}
|
|
333
|
+
return `Question recorded for later (${id}). Awaiting user input — do not act on assumptions about this answer.`;
|
|
334
|
+
}
|
|
335
|
+
export const reviewIngestTools = {
|
|
336
|
+
DEFS: REVIEW_DEFS,
|
|
337
|
+
LABELS: REVIEW_LABELS,
|
|
338
|
+
execute: reviewExecute,
|
|
339
|
+
};
|