plasalid 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +213 -0
- package/README.md +176 -0
- package/dist/accounts/taxonomy.d.ts +31 -0
- package/dist/accounts/taxonomy.js +189 -0
- package/dist/ai/agent.d.ts +43 -0
- package/dist/ai/agent.js +155 -0
- package/dist/ai/context.d.ts +4 -0
- package/dist/ai/context.js +33 -0
- package/dist/ai/memory.d.ts +14 -0
- package/dist/ai/memory.js +12 -0
- package/dist/ai/provider.d.ts +67 -0
- package/dist/ai/provider.js +5 -0
- package/dist/ai/providers/anthropic.d.ts +5 -0
- package/dist/ai/providers/anthropic.js +49 -0
- package/dist/ai/providers/index.d.ts +2 -0
- package/dist/ai/providers/index.js +12 -0
- package/dist/ai/providers/openai-compat.d.ts +5 -0
- package/dist/ai/providers/openai-compat.js +147 -0
- package/dist/ai/providers/openai.d.ts +5 -0
- package/dist/ai/providers/openai.js +147 -0
- package/dist/ai/redactor.d.ts +2 -0
- package/dist/ai/redactor.js +91 -0
- package/dist/ai/sanitize.d.ts +14 -0
- package/dist/ai/sanitize.js +25 -0
- package/dist/ai/system-prompt.d.ts +13 -0
- package/dist/ai/system-prompt.js +174 -0
- package/dist/ai/thai-taxonomy-hint.d.ts +8 -0
- package/dist/ai/thai-taxonomy-hint.js +22 -0
- package/dist/ai/thinking-phrases.d.ts +7 -0
- package/dist/ai/thinking-phrases.js +15 -0
- package/dist/ai/thinking.d.ts +7 -0
- package/dist/ai/thinking.js +15 -0
- package/dist/ai/tools/common.d.ts +2 -0
- package/dist/ai/tools/common.js +83 -0
- package/dist/ai/tools/index.d.ts +8 -0
- package/dist/ai/tools/index.js +34 -0
- package/dist/ai/tools/ingest.d.ts +2 -0
- package/dist/ai/tools/ingest.js +202 -0
- package/dist/ai/tools/read.d.ts +2 -0
- package/dist/ai/tools/read.js +123 -0
- package/dist/ai/tools/reconcile.d.ts +2 -0
- package/dist/ai/tools/reconcile.js +227 -0
- package/dist/ai/tools/scan.d.ts +2 -0
- package/dist/ai/tools/scan.js +24 -0
- package/dist/ai/tools/types.d.ts +26 -0
- package/dist/ai/tools/types.js +1 -0
- package/dist/ai/tools.d.ts +18 -0
- package/dist/ai/tools.js +402 -0
- package/dist/cli/chat.d.ts +1 -0
- package/dist/cli/chat.js +28 -0
- package/dist/cli/commands/accounts.d.ts +1 -0
- package/dist/cli/commands/accounts.js +86 -0
- package/dist/cli/commands/data.d.ts +1 -0
- package/dist/cli/commands/data.js +28 -0
- package/dist/cli/commands/reconcile.d.ts +2 -0
- package/dist/cli/commands/reconcile.js +15 -0
- package/dist/cli/commands/revert.d.ts +1 -0
- package/dist/cli/commands/revert.js +68 -0
- package/dist/cli/commands/scan.d.ts +4 -0
- package/dist/cli/commands/scan.js +45 -0
- package/dist/cli/commands/status.d.ts +1 -0
- package/dist/cli/commands/status.js +22 -0
- package/dist/cli/commands/transactions.d.ts +8 -0
- package/dist/cli/commands/transactions.js +92 -0
- package/dist/cli/commands/undo.d.ts +1 -0
- package/dist/cli/commands/undo.js +38 -0
- package/dist/cli/commands.d.ts +14 -0
- package/dist/cli/commands.js +196 -0
- package/dist/cli/format.d.ts +8 -0
- package/dist/cli/format.js +109 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +126 -0
- package/dist/cli/ink/ChatApp.d.ts +8 -0
- package/dist/cli/ink/ChatApp.js +94 -0
- package/dist/cli/ink/PromptFrame.d.ts +10 -0
- package/dist/cli/ink/PromptFrame.js +11 -0
- package/dist/cli/ink/TextInput.d.ts +13 -0
- package/dist/cli/ink/TextInput.js +24 -0
- package/dist/cli/ink/hooks/useAgent.d.ts +27 -0
- package/dist/cli/ink/hooks/useAgent.js +65 -0
- package/dist/cli/ink/hooks/useCtrlCExit.d.ts +16 -0
- package/dist/cli/ink/hooks/useCtrlCExit.js +43 -0
- package/dist/cli/ink/hooks/useFooterText.d.ts +2 -0
- package/dist/cli/ink/hooks/useFooterText.js +43 -0
- package/dist/cli/ink/hooks/useTextInput.d.ts +32 -0
- package/dist/cli/ink/hooks/useTextInput.js +356 -0
- package/dist/cli/ink/messages/AssistantMessage.d.ts +3 -0
- package/dist/cli/ink/messages/AssistantMessage.js +6 -0
- package/dist/cli/ink/messages/ErrorMessage.d.ts +4 -0
- package/dist/cli/ink/messages/ErrorMessage.js +6 -0
- package/dist/cli/ink/messages/InterruptedMessage.d.ts +1 -0
- package/dist/cli/ink/messages/InterruptedMessage.js +6 -0
- package/dist/cli/ink/messages/ThinkingLine.d.ts +12 -0
- package/dist/cli/ink/messages/ThinkingLine.js +23 -0
- package/dist/cli/ink/messages/UserMessage.d.ts +4 -0
- package/dist/cli/ink/messages/UserMessage.js +15 -0
- package/dist/cli/ink/mount.d.ts +6 -0
- package/dist/cli/ink/mount.js +12 -0
- package/dist/cli/logo.d.ts +1 -0
- package/dist/cli/logo.js +20 -0
- package/dist/cli/setup.d.ts +2 -0
- package/dist/cli/setup.js +210 -0
- package/dist/cli/ux.d.ts +38 -0
- package/dist/cli/ux.js +104 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.js +66 -0
- package/dist/currency.d.ts +6 -0
- package/dist/currency.js +19 -0
- package/dist/db/connection.d.ts +5 -0
- package/dist/db/connection.js +45 -0
- package/dist/db/encryption.d.ts +11 -0
- package/dist/db/encryption.js +45 -0
- package/dist/db/helpers.d.ts +16 -0
- package/dist/db/helpers.js +45 -0
- package/dist/db/queries/account_balance.d.ts +61 -0
- package/dist/db/queries/account_balance.js +146 -0
- package/dist/db/queries/journal.d.ts +95 -0
- package/dist/db/queries/journal.js +204 -0
- package/dist/db/queries/search.d.ts +7 -0
- package/dist/db/queries/search.js +19 -0
- package/dist/db/schema.d.ts +2 -0
- package/dist/db/schema.js +95 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/parser/pdf.d.ts +14 -0
- package/dist/parser/pdf.js +40 -0
- package/dist/parser/pipeline.d.ts +44 -0
- package/dist/parser/pipeline.js +160 -0
- package/dist/parser/prompts.d.ts +8 -0
- package/dist/parser/prompts.js +20 -0
- package/dist/parser/walker.d.ts +8 -0
- package/dist/parser/walker.js +42 -0
- package/dist/reconciler/pipeline.d.ts +17 -0
- package/dist/reconciler/pipeline.js +45 -0
- package/dist/reconciler/prompts.d.ts +12 -0
- package/dist/reconciler/prompts.js +22 -0
- package/dist/scanner/password-store.d.ts +34 -0
- package/dist/scanner/password-store.js +83 -0
- package/dist/scanner/pdf-unlock.d.ts +17 -0
- package/dist/scanner/pdf-unlock.js +48 -0
- package/dist/scanner/pdf.d.ts +17 -0
- package/dist/scanner/pdf.js +36 -0
- package/dist/scanner/pipeline.d.ts +32 -0
- package/dist/scanner/pipeline.js +137 -0
- package/dist/scanner/prompts.d.ts +8 -0
- package/dist/scanner/prompts.js +20 -0
- package/dist/scanner/state-machine.d.ts +60 -0
- package/dist/scanner/state-machine.js +64 -0
- package/dist/scanner/unlock.d.ts +24 -0
- package/dist/scanner/unlock.js +122 -0
- package/dist/scanner/walker.d.ts +8 -0
- package/dist/scanner/walker.js +42 -0
- package/package.json +65 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
import { readContext } from "./context.js";
|
|
3
|
+
function buildRedactions() {
|
|
4
|
+
const entries = [];
|
|
5
|
+
const seen = new Set();
|
|
6
|
+
function add(real, token) {
|
|
7
|
+
const trimmed = real.trim();
|
|
8
|
+
if (trimmed.length < 2 || seen.has(trimmed.toLowerCase()))
|
|
9
|
+
return;
|
|
10
|
+
seen.add(trimmed.toLowerCase());
|
|
11
|
+
entries.push({ real: trimmed, token });
|
|
12
|
+
}
|
|
13
|
+
const userName = config.userName;
|
|
14
|
+
if (userName && userName !== "User") {
|
|
15
|
+
add(userName, "[USER]");
|
|
16
|
+
const parts = userName.split(/\s+/);
|
|
17
|
+
if (parts.length > 1) {
|
|
18
|
+
add(parts[0], "[USER_FIRST]");
|
|
19
|
+
add(parts[parts.length - 1], "[USER_LAST]");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const context = readContext();
|
|
23
|
+
if (context) {
|
|
24
|
+
const familyMatch = context.match(/## Family\n([\s\S]*?)(?=\n##|$)/);
|
|
25
|
+
if (familyMatch) {
|
|
26
|
+
const lines = familyMatch[1].split("\n").filter(l => l.trim().startsWith("-"));
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
const text = line.replace(/^-\s*/, "").trim();
|
|
29
|
+
if (!text || text.startsWith("(") || text.toLowerCase() === userName.toLowerCase())
|
|
30
|
+
continue;
|
|
31
|
+
const nameMatch = text.match(/^(?:partner|spouse|wife|husband|child|kid|son|daughter|dependent)[:\s]+(.+)/i)
|
|
32
|
+
|| text.match(/^([\p{Lu}\p{Lo}][\p{L}\s]+)/u);
|
|
33
|
+
if (nameMatch) {
|
|
34
|
+
const name = nameMatch[1].replace(/\s*\(.*\)/, "").trim();
|
|
35
|
+
if (name && name.toLowerCase() !== userName.toLowerCase()) {
|
|
36
|
+
add(name, "[PARTNER]");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const incomeMatch = context.match(/## Income\n([\s\S]*?)(?=\n##|$)/);
|
|
42
|
+
if (incomeMatch) {
|
|
43
|
+
const lines = incomeMatch[1].split("\n").filter(l => l.trim().startsWith("-"));
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
const text = line.replace(/^-\s*/, "").trim();
|
|
46
|
+
if (!text || text.startsWith("("))
|
|
47
|
+
continue;
|
|
48
|
+
const employerMatch = text.match(/(?:employer|works? (?:at|for)|employed (?:at|by))[:\s]+([A-Z][\w\s&.,-]+?)(?:\s*[-–—|,;(\n]|$)/i)
|
|
49
|
+
|| text.match(/\bfrom ([A-Z][A-Za-z\s&.,-]+?)(?:\s*[-–—|,;(\n]|$)/)
|
|
50
|
+
|| text.match(/\bat ([A-Z][A-Za-z\s&.,-]+?)(?:\s*[-–—|,;(\n]|$)/);
|
|
51
|
+
if (employerMatch) {
|
|
52
|
+
add(employerMatch[1].trim(), "[EMPLOYER]");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
entries.sort((a, b) => b.real.length - a.real.length);
|
|
58
|
+
return entries;
|
|
59
|
+
}
|
|
60
|
+
// Patterns for numeric / identifier PII commonly found in Thai financial data.
|
|
61
|
+
const NUMERIC_PII_PATTERNS = [
|
|
62
|
+
// Thai national ID with dashes: 1-2345-67890-12-3
|
|
63
|
+
[/\b\d-\d{4}-\d{5}-\d{2}-\d\b/g, "[NATID]"],
|
|
64
|
+
// Thai national ID without dashes (13 digits) — must precede the generic ACCT pattern.
|
|
65
|
+
[/\b\d{13}\b/g, "[NATID]"],
|
|
66
|
+
// Thai mobile numbers: 0[689]xxxxxxxx (10 digits starting 06/08/09)
|
|
67
|
+
[/\b0[689]\d{8}\b/g, "[PHONE]"],
|
|
68
|
+
// 16-digit credit card (with optional separators)
|
|
69
|
+
[/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g, "[CARD]"],
|
|
70
|
+
// 10–12 digit account / routing numbers at a word boundary
|
|
71
|
+
[/\b\d{10,12}\b(?=\s|$|[,.])/g, "[ACCT]"],
|
|
72
|
+
];
|
|
73
|
+
export function redact(text) {
|
|
74
|
+
const redactions = buildRedactions();
|
|
75
|
+
let result = text;
|
|
76
|
+
for (const { real, token } of redactions) {
|
|
77
|
+
result = result.replaceAll(real, token);
|
|
78
|
+
}
|
|
79
|
+
for (const [pattern, replacement] of NUMERIC_PII_PATTERNS) {
|
|
80
|
+
result = result.replace(pattern, replacement);
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
export function unredact(text) {
|
|
85
|
+
const redactions = buildRedactions();
|
|
86
|
+
let result = text;
|
|
87
|
+
for (const { real, token } of redactions) {
|
|
88
|
+
result = result.replaceAll(token, real);
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defensive helpers for interpolating user / model-controlled text into
|
|
3
|
+
* tool-output strings that flow back to the LLM. They strip control characters
|
|
4
|
+
* that could smuggle instructions across data/instruction boundaries.
|
|
5
|
+
*/
|
|
6
|
+
/** Strip control characters (newlines, tabs, ANSI escapes) from text. */
|
|
7
|
+
export declare function stripControls(text: string): string;
|
|
8
|
+
/** Sanitize text for prompt interpolation (no length clip — keep full content). */
|
|
9
|
+
export declare function sanitizeForPrompt(text: string | null | undefined): string;
|
|
10
|
+
/**
|
|
11
|
+
* Sanitize for use inside a `|`-delimited row. Replaces literal `|` characters
|
|
12
|
+
* with `/` so a memo / account name containing `|` can't spoof extra columns.
|
|
13
|
+
*/
|
|
14
|
+
export declare function sanitizeForPromptCell(text: string | null | undefined): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defensive helpers for interpolating user / model-controlled text into
|
|
3
|
+
* tool-output strings that flow back to the LLM. They strip control characters
|
|
4
|
+
* that could smuggle instructions across data/instruction boundaries.
|
|
5
|
+
*/
|
|
6
|
+
/** Strip control characters (newlines, tabs, ANSI escapes) from text. */
|
|
7
|
+
export function stripControls(text) {
|
|
8
|
+
// eslint-disable-next-line no-control-regex
|
|
9
|
+
return text.replace(/[\x00-\x1F\x7F]/g, " ").trim();
|
|
10
|
+
}
|
|
11
|
+
/** Sanitize text for prompt interpolation (no length clip — keep full content). */
|
|
12
|
+
export function sanitizeForPrompt(text) {
|
|
13
|
+
if (!text)
|
|
14
|
+
return "";
|
|
15
|
+
return stripControls(text);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Sanitize for use inside a `|`-delimited row. Replaces literal `|` characters
|
|
19
|
+
* with `/` so a memo / account name containing `|` can't spoof extra columns.
|
|
20
|
+
*/
|
|
21
|
+
export function sanitizeForPromptCell(text) {
|
|
22
|
+
if (!text)
|
|
23
|
+
return "";
|
|
24
|
+
return stripControls(text).replace(/\|/g, "/");
|
|
25
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
export declare function buildChatSystemPrompt(db: Database.Database): string;
|
|
3
|
+
export interface ScanPromptOptions {
|
|
4
|
+
fileName: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ReconcilePromptOptions {
|
|
7
|
+
accountId?: string;
|
|
8
|
+
from?: string;
|
|
9
|
+
to?: string;
|
|
10
|
+
dryRun: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function buildReconcileSystemPrompt(db: Database.Database, opts: ReconcilePromptOptions): string;
|
|
13
|
+
export declare function buildScanSystemPrompt(db: Database.Database, opts: ScanPromptOptions): string;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
import { getMemories } from "./memory.js";
|
|
3
|
+
import { readContext } from "./context.js";
|
|
4
|
+
import { stripControls } from "./sanitize.js";
|
|
5
|
+
import { getAccountBalances } from "../db/queries/account_balance.js";
|
|
6
|
+
import { getThaiTaxonomyHint } from "../accounts/taxonomy.js";
|
|
7
|
+
function chatPersona(name) {
|
|
8
|
+
return `Your name is Plasalid ("ปลาสลิด") — You're ${name}'s personal money coach. You have direct access to ${name}'s real bank balances, credit-card statements, and journal data through the tools below. Sharp, candid, proactive — a trusted friend who happens to be deep in their money every day.
|
|
9
|
+
|
|
10
|
+
## How you talk
|
|
11
|
+
- Lead with the insight, not the data. Don't say "Here's the breakdown." Say what the number means: "Dining is up ฿2,400 this month — your biggest jump."
|
|
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.`;
|
|
65
|
+
export function buildChatSystemPrompt(db) {
|
|
66
|
+
const memories = getMemories(db);
|
|
67
|
+
const context = readContext();
|
|
68
|
+
const name = config.userName;
|
|
69
|
+
const now = new Date();
|
|
70
|
+
const dateStr = now.toLocaleDateString("en-GB", {
|
|
71
|
+
weekday: "long",
|
|
72
|
+
month: "long",
|
|
73
|
+
day: "numeric",
|
|
74
|
+
year: "numeric",
|
|
75
|
+
});
|
|
76
|
+
let prompt = `${chatPersona(name)}\n\nToday is ${dateStr}.`;
|
|
77
|
+
prompt += `\n\n## About ${name}\n`;
|
|
78
|
+
prompt += context
|
|
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;
|
|
98
|
+
}
|
|
99
|
+
const RECONCILE_PERSONA = `You are Plasalid's reconciler. You revisit existing journal entries and accounts to fix data-quality issues: duplicate transactions, mis-categorized lines, inconsistent account naming, missing metadata, sign-convention slips.
|
|
100
|
+
|
|
101
|
+
Hard rules:
|
|
102
|
+
1. Survey first. Call list_accounts and get_net_worth, then use find_duplicate_entries, find_similar_accounts, and find_unused_accounts to gather candidate issues within the user's requested scope.
|
|
103
|
+
2. For every write you propose, call ask_user with concrete options. Wait for the user's confirmation before applying.
|
|
104
|
+
3. Conservative defaults: when uncertain, save_memory and skip. Never delete without explicit user confirmation. If dry-run is enabled, the write tools will return "Would ..." messages — relay those to the user without further action.
|
|
105
|
+
4. Bookkeeping rules still apply. record_journal_entry must balance. For amount fixes, delete the broken entry and record a fresh replacement.
|
|
106
|
+
5. Stop when there's nothing material left to fix. Call mark_reconcile_done with a short summary (counts of merges/deletes/edits applied).
|
|
107
|
+
|
|
108
|
+
How to phrase ask_user:
|
|
109
|
+
- Frame each question as a complete sentence with enough context for the user to decide quickly. Include the date, the amount (formatted as ฿N,NNN.NN), the description, and the affected account names.
|
|
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;
|
|
152
|
+
}
|
|
153
|
+
export function buildScanSystemPrompt(db, opts) {
|
|
154
|
+
const balances = getAccountBalances(db);
|
|
155
|
+
const memories = getMemories(db);
|
|
156
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
157
|
+
let prompt = `${SCAN_PERSONA}\n\nToday is ${today}.\n\n## Current chart of accounts\n`;
|
|
158
|
+
prompt +=
|
|
159
|
+
balances.length === 0
|
|
160
|
+
? "(empty — you may need to create accounts)"
|
|
161
|
+
: balances
|
|
162
|
+
.map((a) => `- ${a.id} | ${a.name} | ${a.type}${a.subtype ? `/${a.subtype}` : ""}`)
|
|
163
|
+
.join("\n");
|
|
164
|
+
prompt += `\n\n## File context\nFile: ${opts.fileName}`;
|
|
165
|
+
prompt += `\n\n## Taxonomy hints\n${getThaiTaxonomyHint()}`;
|
|
166
|
+
if (memories.length > 0) {
|
|
167
|
+
prompt += `\n\n## Saved scanning hints (memories)\n`;
|
|
168
|
+
prompt += memories
|
|
169
|
+
.filter((m) => m.category === "scanning_hint" || m.category === "general")
|
|
170
|
+
.map((m) => `- ${stripControls(m.content)}`)
|
|
171
|
+
.join("\n");
|
|
172
|
+
}
|
|
173
|
+
return prompt;
|
|
174
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stringified **Thai** taxonomy block for the scan/reconcile system prompts.
|
|
3
|
+
* Listing known Thai institutions inline gives the model anchors for
|
|
4
|
+
* `bank_name` normalization. Subtype hints help the model pick a consistent
|
|
5
|
+
* `subtype`. Plasalid is currently Thailand-focused; if/when we expand to
|
|
6
|
+
* other locales this helper splits into per-locale variants.
|
|
7
|
+
*/
|
|
8
|
+
export declare function getThaiTaxonomyHint(): string;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ALL_THAI_INSTITUTIONS, SUGGESTED_ASSET_SUBTYPES, SUGGESTED_LIABILITY_SUBTYPES, SUGGESTED_EXPENSE_SUBTYPES, SUGGESTED_INCOME_SUBTYPES, } from "../accounts/taxonomy.js";
|
|
2
|
+
/**
|
|
3
|
+
* Stringified **Thai** taxonomy block for the scan/reconcile system prompts.
|
|
4
|
+
* Listing known Thai institutions inline gives the model anchors for
|
|
5
|
+
* `bank_name` normalization. Subtype hints help the model pick a consistent
|
|
6
|
+
* `subtype`. Plasalid is currently Thailand-focused; if/when we expand to
|
|
7
|
+
* other locales this helper splits into per-locale variants.
|
|
8
|
+
*/
|
|
9
|
+
export function getThaiTaxonomyHint() {
|
|
10
|
+
const institutions = ALL_THAI_INSTITUTIONS
|
|
11
|
+
.map(i => `${i.code} (${i.label}, ${i.kind})${i.notes ? ` — ${i.notes}` : ""}`)
|
|
12
|
+
.join("\n");
|
|
13
|
+
return [
|
|
14
|
+
`Known Thai institutions:`,
|
|
15
|
+
institutions,
|
|
16
|
+
``,
|
|
17
|
+
`Suggested asset subtypes: ${SUGGESTED_ASSET_SUBTYPES.join(", ")}`,
|
|
18
|
+
`Suggested liability subtypes: ${SUGGESTED_LIABILITY_SUBTYPES.join(", ")}`,
|
|
19
|
+
`Suggested expense subtypes: ${SUGGESTED_EXPENSE_SUBTYPES.join(", ")}`,
|
|
20
|
+
`Suggested income subtypes: ${SUGGESTED_INCOME_SUBTYPES.join(", ")}`,
|
|
21
|
+
].join("\n");
|
|
22
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idle/thinking phrases used by the chat hook and the scan spinner when the
|
|
3
|
+
* AI is composing a response (no specific tool to label). Kept in one place so
|
|
4
|
+
* both surfaces stay in sync.
|
|
5
|
+
*/
|
|
6
|
+
export declare const THINKING_PHRASES: string[];
|
|
7
|
+
export declare function pickThinkingPhrase(): string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idle/thinking phrases used by the chat hook and the scan spinner when the
|
|
3
|
+
* AI is composing a response (no specific tool to label). Kept in one place so
|
|
4
|
+
* both surfaces stay in sync.
|
|
5
|
+
*/
|
|
6
|
+
export const THINKING_PHRASES = [
|
|
7
|
+
"Thinking...",
|
|
8
|
+
"Looking through your journal...",
|
|
9
|
+
"Checking your accounts...",
|
|
10
|
+
"Crunching the numbers...",
|
|
11
|
+
"Pulling up your data...",
|
|
12
|
+
];
|
|
13
|
+
export function pickThinkingPhrase() {
|
|
14
|
+
return THINKING_PHRASES[Math.floor(Math.random() * THINKING_PHRASES.length)];
|
|
15
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idle/thinking phrases used by the chat hook and the scan spinner when the
|
|
3
|
+
* AI is composing a response (no specific tool to label). Kept in one place so
|
|
4
|
+
* both surfaces stay in sync.
|
|
5
|
+
*/
|
|
6
|
+
export declare const THINKING_PHRASES: string[];
|
|
7
|
+
export declare function pickThinking(): string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idle/thinking phrases used by the chat hook and the scan spinner when the
|
|
3
|
+
* AI is composing a response (no specific tool to label). Kept in one place so
|
|
4
|
+
* both surfaces stay in sync.
|
|
5
|
+
*/
|
|
6
|
+
export const THINKING_PHRASES = [
|
|
7
|
+
"Thinking...",
|
|
8
|
+
"Looking through your journal...",
|
|
9
|
+
"Checking your accounts...",
|
|
10
|
+
"Crunching the numbers...",
|
|
11
|
+
"Pulling up your data...",
|
|
12
|
+
];
|
|
13
|
+
export function pickThinking() {
|
|
14
|
+
return THINKING_PHRASES[Math.floor(Math.random() * THINKING_PHRASES.length)];
|
|
15
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { saveMemory, getMemories } from "../memory.js";
|
|
2
|
+
import { getAccountBalances } from "../../db/queries/account_balance.js";
|
|
3
|
+
import { formatCurrencyAmount } from "../../currency.js";
|
|
4
|
+
import { sanitizeForPrompt, sanitizeForPromptCell } from "../sanitize.js";
|
|
5
|
+
import { ACCOUNT_TYPE_DESCRIPTIONS } from "../../accounts/taxonomy.js";
|
|
6
|
+
const ACCOUNT_TYPES = Object.keys(ACCOUNT_TYPE_DESCRIPTIONS);
|
|
7
|
+
function formatTHB(amount) {
|
|
8
|
+
return formatCurrencyAmount(amount, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
9
|
+
}
|
|
10
|
+
const DEFS = [
|
|
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 LABELS = {
|
|
41
|
+
list_accounts: "Listing accounts",
|
|
42
|
+
save_memory: "Saving memory",
|
|
43
|
+
get_memories: "Recalling memories",
|
|
44
|
+
};
|
|
45
|
+
async function execute(db, name, input, _ctx) {
|
|
46
|
+
switch (name) {
|
|
47
|
+
case "list_accounts": {
|
|
48
|
+
const accounts = getAccountBalances(db, input?.type ? { type: input.type } : {});
|
|
49
|
+
if (accounts.length === 0)
|
|
50
|
+
return "No accounts in the chart of accounts yet.";
|
|
51
|
+
return accounts
|
|
52
|
+
.map(a => {
|
|
53
|
+
const meta = [];
|
|
54
|
+
if (a.bank_name)
|
|
55
|
+
meta.push(sanitizeForPrompt(a.bank_name));
|
|
56
|
+
if (a.account_number_masked)
|
|
57
|
+
meta.push(sanitizeForPrompt(a.account_number_masked));
|
|
58
|
+
if (a.due_day)
|
|
59
|
+
meta.push(`due day ${a.due_day}`);
|
|
60
|
+
if (a.points_balance)
|
|
61
|
+
meta.push(`${a.points_balance} pts`);
|
|
62
|
+
const metaStr = meta.length ? ` [${meta.join(" · ")}]` : "";
|
|
63
|
+
return `${a.id} | ${sanitizeForPromptCell(a.name)} | ${a.type}${a.subtype ? `/${a.subtype}` : ""} | balance ${formatTHB(a.balance)}${metaStr}`;
|
|
64
|
+
})
|
|
65
|
+
.join("\n");
|
|
66
|
+
}
|
|
67
|
+
case "save_memory": {
|
|
68
|
+
saveMemory(db, input.content, input.category || "general");
|
|
69
|
+
return `Saved memory: "${sanitizeForPrompt(input.content)}"`;
|
|
70
|
+
}
|
|
71
|
+
case "get_memories": {
|
|
72
|
+
const memories = getMemories(db);
|
|
73
|
+
if (memories.length === 0)
|
|
74
|
+
return "No memories saved yet.";
|
|
75
|
+
return memories
|
|
76
|
+
.map(m => `[${m.category}] ${sanitizeForPrompt(m.content)} (saved ${m.created_at})`)
|
|
77
|
+
.join("\n");
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export const commonTools = { DEFS, LABELS, execute };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
import type { ToolDefinition } from "../provider.js";
|
|
3
|
+
import type { AgentExecutionContext, ToolProfile } from "./types.js";
|
|
4
|
+
export type { AgentExecutionContext, ToolProfile } from "./types.js";
|
|
5
|
+
export declare function getToolDefinitions(profile: ToolProfile): ToolDefinition[];
|
|
6
|
+
export declare function executeTool(db: Database.Database, name: string, input: any, ctx?: AgentExecutionContext): Promise<string>;
|
|
7
|
+
/** Human-readable labels shown in the spinner during tool calls. */
|
|
8
|
+
export declare const TOOL_LABELS: Record<string, string>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { commonTools } from "./common.js";
|
|
2
|
+
import { readTools } from "./read.js";
|
|
3
|
+
import { ingestTools } from "./ingest.js";
|
|
4
|
+
import { scanTools } from "./scan.js";
|
|
5
|
+
import { reconcileTools } from "./reconcile.js";
|
|
6
|
+
/**
|
|
7
|
+
* Profile composition. Each profile is the union of one or more tool modules;
|
|
8
|
+
* the dispatcher iterates every module on each tool call so we never need a
|
|
9
|
+
* central switch.
|
|
10
|
+
*/
|
|
11
|
+
const PROFILES = {
|
|
12
|
+
scan: [commonTools, ingestTools, scanTools],
|
|
13
|
+
chat: [commonTools, readTools],
|
|
14
|
+
reconcile: [commonTools, readTools, ingestTools, reconcileTools],
|
|
15
|
+
};
|
|
16
|
+
export function getToolDefinitions(profile) {
|
|
17
|
+
return PROFILES[profile].flatMap(m => m.DEFS);
|
|
18
|
+
}
|
|
19
|
+
export async function executeTool(db, name, input, ctx) {
|
|
20
|
+
for (const mod of [commonTools, readTools, ingestTools, scanTools, reconcileTools]) {
|
|
21
|
+
const result = await mod.execute(db, name, input, ctx);
|
|
22
|
+
if (result !== undefined)
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
return `Unknown tool: ${name}`;
|
|
26
|
+
}
|
|
27
|
+
/** Human-readable labels shown in the spinner during tool calls. */
|
|
28
|
+
export const TOOL_LABELS = {
|
|
29
|
+
...commonTools.LABELS,
|
|
30
|
+
...readTools.LABELS,
|
|
31
|
+
...ingestTools.LABELS,
|
|
32
|
+
...scanTools.LABELS,
|
|
33
|
+
...reconcileTools.LABELS,
|
|
34
|
+
};
|