plasalid 0.3.5 → 0.5.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 (81) hide show
  1. package/README.md +33 -43
  2. package/dist/accounts/taxonomy.d.ts +1 -1
  3. package/dist/accounts/taxonomy.js +2 -2
  4. package/dist/ai/agent.d.ts +19 -5
  5. package/dist/ai/agent.js +26 -6
  6. package/dist/ai/memory.d.ts +14 -5
  7. package/dist/ai/memory.js +12 -0
  8. package/dist/ai/personas.d.ts +11 -0
  9. package/dist/ai/personas.js +193 -0
  10. package/dist/ai/prompt-sections.d.ts +49 -0
  11. package/dist/ai/prompt-sections.js +107 -0
  12. package/dist/ai/system-prompt.d.ts +14 -3
  13. package/dist/ai/system-prompt.js +59 -165
  14. package/dist/ai/thinking.js +1 -1
  15. package/dist/ai/tools/common.js +2 -5
  16. package/dist/ai/tools/index.js +32 -7
  17. package/dist/ai/tools/ingest.d.ts +3 -1
  18. package/dist/ai/tools/ingest.js +372 -124
  19. package/dist/ai/tools/merchants.d.ts +2 -0
  20. package/dist/ai/tools/merchants.js +117 -0
  21. package/dist/ai/tools/read.js +57 -24
  22. package/dist/ai/tools/record.d.ts +2 -0
  23. package/dist/ai/tools/record.js +188 -0
  24. package/dist/ai/tools/review.d.ts +2 -0
  25. package/dist/ai/tools/review.js +359 -0
  26. package/dist/ai/tools/scan.js +5 -3
  27. package/dist/ai/tools/types.d.ts +33 -4
  28. package/dist/cli/commands/accounts.js +33 -25
  29. package/dist/cli/commands/record.d.ts +4 -0
  30. package/dist/cli/commands/record.js +119 -0
  31. package/dist/cli/commands/revert.js +1 -1
  32. package/dist/cli/commands/review.d.ts +2 -0
  33. package/dist/cli/commands/review.js +15 -0
  34. package/dist/cli/commands/scan.d.ts +4 -2
  35. package/dist/cli/commands/scan.js +143 -19
  36. package/dist/cli/commands/status.js +6 -9
  37. package/dist/cli/commands/transactions.js +36 -41
  38. package/dist/cli/format.d.ts +2 -0
  39. package/dist/cli/format.js +7 -2
  40. package/dist/cli/index.js +28 -13
  41. package/dist/cli/ink/scan_dashboard.d.ts +38 -0
  42. package/dist/cli/ink/scan_dashboard.js +62 -0
  43. package/dist/cli/setup.d.ts +0 -1
  44. package/dist/cli/setup.js +2 -8
  45. package/dist/cli/ux.d.ts +2 -1
  46. package/dist/cli/ux.js +36 -2
  47. package/dist/currency.d.ts +3 -0
  48. package/dist/currency.js +12 -1
  49. package/dist/db/queries/account_balance.d.ts +84 -4
  50. package/dist/db/queries/account_balance.js +239 -20
  51. package/dist/db/queries/action_log.d.ts +29 -0
  52. package/dist/db/queries/action_log.js +27 -0
  53. package/dist/db/queries/concerns.d.ts +50 -0
  54. package/dist/db/queries/concerns.js +91 -0
  55. package/dist/db/queries/journal.d.ts +75 -8
  56. package/dist/db/queries/journal.js +131 -19
  57. package/dist/db/queries/merchants.d.ts +42 -0
  58. package/dist/db/queries/merchants.js +120 -0
  59. package/dist/db/queries/recurrences.d.ts +33 -0
  60. package/dist/db/queries/recurrences.js +128 -0
  61. package/dist/db/queries/search.d.ts +5 -4
  62. package/dist/db/queries/search.js +16 -12
  63. package/dist/db/queries/transactions.d.ts +167 -0
  64. package/dist/db/queries/transactions.js +320 -0
  65. package/dist/db/schema.js +74 -9
  66. package/dist/reviewer/pipeline.d.ts +18 -0
  67. package/dist/reviewer/pipeline.js +46 -0
  68. package/dist/reviewer/prompts.d.ts +12 -0
  69. package/dist/reviewer/prompts.js +22 -0
  70. package/dist/scanner/account_mutex.d.ts +1 -0
  71. package/dist/scanner/account_mutex.js +16 -0
  72. package/dist/scanner/buffer.d.ts +51 -0
  73. package/dist/scanner/buffer.js +63 -0
  74. package/dist/scanner/concurrency.d.ts +14 -0
  75. package/dist/scanner/concurrency.js +31 -0
  76. package/dist/scanner/decrypt_queue.d.ts +57 -0
  77. package/dist/scanner/decrypt_queue.js +96 -0
  78. package/dist/scanner/pipeline.d.ts +47 -18
  79. package/dist/scanner/pipeline.js +247 -97
  80. package/dist/scanner/prompts.js +3 -3
  81. package/package.json +2 -2
@@ -0,0 +1,107 @@
1
+ import { getMemories } from "./memory.js";
2
+ import { getAccountBalances } from "../db/queries/account_balance.js";
3
+ import { stripControls } from "./sanitize.js";
4
+ /**
5
+ * Small, single-purpose renderers that produce one Markdown-ish section each.
6
+ * Builders compose them; each helper either returns a string or null (omit).
7
+ *
8
+ * Style:
9
+ * - No accumulation (`let prompt = …; prompt += …`).
10
+ * - No per-call branching the caller can't see — section options stay tiny.
11
+ * - String building stays in the helper; the builder only chooses *which*
12
+ * helpers to call and *in what order*.
13
+ */
14
+ /** Date headers */
15
+ /** Long-form date for chat ("Today is Friday, March 5, 2026."). */
16
+ export function renderTodayHuman() {
17
+ return `Today is ${new Date().toLocaleDateString("en-GB", {
18
+ weekday: "long",
19
+ month: "long",
20
+ day: "numeric",
21
+ year: "numeric",
22
+ })}.`;
23
+ }
24
+ /** ISO date for scan/review ("Today is 2026-03-05."). */
25
+ export function renderTodayIso() {
26
+ return `Today is ${new Date().toISOString().slice(0, 10)}.`;
27
+ }
28
+ export function renderChartOfAccounts(db, opts) {
29
+ const balances = getAccountBalances(db);
30
+ if (balances.length === 0) {
31
+ const empty = opts.emptyState === "scan"
32
+ ? "(empty — you may need to create accounts; remember to pass parent_id under one of asset/liability/income/expense/equity)"
33
+ : "(empty)";
34
+ return `## Current chart of accounts\n${empty}`;
35
+ }
36
+ const rows = renderHierarchical(balances, opts.withBalance);
37
+ return `## Current chart of accounts\n${rows.join("\n")}`;
38
+ }
39
+ /**
40
+ * Chat's chart section has a different empty-state shape — it replaces the
41
+ * whole "## Current chart of accounts" header with a user-facing call to
42
+ * action that mentions the user by name. Worth its own helper instead of
43
+ * branching the generic one.
44
+ */
45
+ export function renderChatChartOrEmpty(db, name) {
46
+ const balances = getAccountBalances(db);
47
+ if (balances.length === 0) {
48
+ return `No accounts have been scanned yet. ${name} should drop files into ~/.plasalid/data/ and run \`plasalid scan\`.`;
49
+ }
50
+ const rows = renderHierarchical(balances, true);
51
+ return `## Accounts on file\n${rows.join("\n")}`;
52
+ }
53
+ function renderHierarchical(balances, withBalance) {
54
+ const byId = new Map(balances.map(b => [b.id, b]));
55
+ const depthCache = new Map();
56
+ const depth = (id) => {
57
+ if (depthCache.has(id))
58
+ return depthCache.get(id);
59
+ const node = byId.get(id);
60
+ if (!node || !node.parent_id) {
61
+ depthCache.set(id, 0);
62
+ return 0;
63
+ }
64
+ const d = depth(node.parent_id) + 1;
65
+ depthCache.set(id, d);
66
+ return d;
67
+ };
68
+ return balances.map(a => formatAccountRow(a, withBalance, depth(a.id)));
69
+ }
70
+ export function renderMemories(db, opts) {
71
+ const all = getMemories(db);
72
+ const filtered = opts.filterCategories
73
+ ? all.filter(m => opts.filterCategories.includes(m.category))
74
+ : all;
75
+ if (filtered.length === 0)
76
+ return null;
77
+ const lines = filtered.map(m => formatMemoryLine(m, opts.showCategory));
78
+ return `## ${opts.header}\n${lines.join("\n")}`;
79
+ }
80
+ export function renderScope(opts) {
81
+ return [
82
+ "## Scope",
83
+ `- account: ${opts.accountId ?? "all"}`,
84
+ `- from: ${opts.from ?? "all time"}`,
85
+ `- to: ${opts.to ?? "now"}`,
86
+ `- dry run: ${opts.dryRun
87
+ ? "yes — write tools will not mutate the DB"
88
+ : "no — write tools will mutate the DB after confirmation"}`,
89
+ ].join("\n");
90
+ }
91
+ /** Chat user context */
92
+ export function renderUserContext(name, contextMd) {
93
+ const body = contextMd ?? `(No personal context on file yet. ${name} can edit ~/.plasalid/context.md to add family, income, or other facts.)`;
94
+ return `## About ${name}\n${body}`;
95
+ }
96
+ /** Internal formatters */
97
+ function formatAccountRow(a, withBalance, depth = 0) {
98
+ const indent = " ".repeat(depth);
99
+ const subtype = a.subtype ? `/${a.subtype}` : "";
100
+ const base = `- ${indent}${a.id} | ${a.name} | ${a.type}${subtype}`;
101
+ return withBalance ? `${base} | balance ${a.balance.toFixed(2)} ${a.currency}` : base;
102
+ }
103
+ function formatMemoryLine(m, showCategory) {
104
+ return showCategory
105
+ ? `- [${m.category}] ${stripControls(m.content)}`
106
+ : `- ${stripControls(m.content)}`;
107
+ }
@@ -1,13 +1,24 @@
1
1
  import type Database from "libsql";
2
- export declare function buildChatSystemPrompt(db: Database.Database): string;
3
2
  export interface ScanPromptOptions {
4
3
  fileName: string;
5
4
  }
6
- export interface ReconcilePromptOptions {
5
+ export interface ReviewPromptOptions {
7
6
  accountId?: string;
8
7
  from?: string;
9
8
  to?: string;
10
9
  dryRun: boolean;
11
10
  }
12
- export declare function buildReconcileSystemPrompt(db: Database.Database, opts: ReconcilePromptOptions): string;
11
+ export interface RecordPromptOptions {
12
+ utterance: string;
13
+ }
14
+ /**
15
+ * Builders
16
+ *
17
+ * Each builder is a list of sections in render order. No accumulation, no
18
+ * inline string assembly. To edit a section, change the helper; to reorder,
19
+ * shuffle the array.
20
+ */
21
+ export declare function buildChatSystemPrompt(db: Database.Database): string;
22
+ export declare function buildReviewSystemPrompt(db: Database.Database, opts: ReviewPromptOptions): string;
23
+ export declare function buildRecordSystemPrompt(db: Database.Database, opts: RecordPromptOptions): string;
13
24
  export declare function buildScanSystemPrompt(db: Database.Database, opts: ScanPromptOptions): string;
@@ -1,174 +1,68 @@
1
1
  import { config } from "../config.js";
2
- import { getMemories } from "./memory.js";
3
2
  import { readContext } from "./context.js";
4
- import { stripControls } from "./sanitize.js";
5
- import { getAccountBalances } from "../db/queries/account_balance.js";
3
+ import { chatPersona, SCAN_PERSONA, REVIEW_PERSONA, RECORD_PERSONA } from "./personas.js";
6
4
  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.`;
5
+ import { renderChartOfAccounts, renderChatChartOrEmpty, renderMemories, renderScope, renderTodayHuman, renderTodayIso, renderUserContext, } from "./prompt-sections.js";
6
+ /**
7
+ * Builders
8
+ *
9
+ * Each builder is a list of sections in render order. No accumulation, no
10
+ * inline string assembly. To edit a section, change the helper; to reorder,
11
+ * shuffle the array.
12
+ */
65
13
  export function buildChatSystemPrompt(db) {
66
- const memories = getMemories(db);
67
- const context = readContext();
68
14
  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;
15
+ return joinSections([
16
+ chatPersona(name),
17
+ renderTodayHuman(),
18
+ renderUserContext(name, readContext()),
19
+ renderChatChartOrEmpty(db, name),
20
+ renderMemories(db, {
21
+ header: `Things to remember about ${name}`,
22
+ showCategory: true,
23
+ }),
24
+ ]);
25
+ }
26
+ export function buildReviewSystemPrompt(db, opts) {
27
+ return joinSections([
28
+ REVIEW_PERSONA,
29
+ renderTodayIso(),
30
+ renderChartOfAccounts(db, { withBalance: true, emptyState: "review" }),
31
+ renderScope(opts),
32
+ renderMemories(db, {
33
+ header: "Rules you've already learned (apply directly; do not re-ask the user)",
34
+ showCategory: true,
35
+ }),
36
+ ]);
98
37
  }
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;
38
+ export function buildRecordSystemPrompt(db, opts) {
39
+ return joinSections([
40
+ RECORD_PERSONA,
41
+ renderTodayIso(),
42
+ renderChartOfAccounts(db, { withBalance: true, emptyState: "scan" }),
43
+ `## What the user said\n> ${opts.utterance.replace(/\n/g, " ")}`,
44
+ renderMemories(db, {
45
+ header: "Rules you've already learned (apply silently)",
46
+ filterCategories: ["scanning_hint", "general", "preference"],
47
+ showCategory: false,
48
+ }),
49
+ ]);
152
50
  }
153
51
  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;
52
+ return joinSections([
53
+ SCAN_PERSONA,
54
+ renderTodayIso(),
55
+ renderChartOfAccounts(db, { withBalance: false, emptyState: "scan" }),
56
+ `## File context\nFile: ${opts.fileName}`,
57
+ `## Taxonomy hints\n${getThaiTaxonomyHint()}`,
58
+ renderMemories(db, {
59
+ header: "Rules you've already learned (apply silently before raising a concern)",
60
+ filterCategories: ["scanning_hint", "general"],
61
+ showCategory: false,
62
+ }),
63
+ ]);
64
+ }
65
+ /** Drop null/empty sections, join the rest with a blank line. */
66
+ function joinSections(sections) {
67
+ return sections.filter((s) => !!s).join("\n\n");
174
68
  }
@@ -5,7 +5,7 @@
5
5
  */
6
6
  export const THINKING_PHRASES = [
7
7
  "Thinking...",
8
- "Looking through your journal...",
8
+ "Looking through your transactions...",
9
9
  "Checking your accounts...",
10
10
  "Crunching the numbers...",
11
11
  "Pulling up your data...",
@@ -1,12 +1,9 @@
1
1
  import { saveMemory, getMemories } from "../memory.js";
2
2
  import { getAccountBalances } from "../../db/queries/account_balance.js";
3
- import { formatCurrencyAmount } from "../../currency.js";
3
+ import { formatAmount } from "../../currency.js";
4
4
  import { sanitizeForPrompt, sanitizeForPromptCell } from "../sanitize.js";
5
5
  import { ACCOUNT_TYPE_DESCRIPTIONS } from "../../accounts/taxonomy.js";
6
6
  const ACCOUNT_TYPES = Object.keys(ACCOUNT_TYPE_DESCRIPTIONS);
7
- function formatTHB(amount) {
8
- return formatCurrencyAmount(amount, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
9
- }
10
7
  const DEFS = [
11
8
  {
12
9
  name: "list_accounts",
@@ -60,7 +57,7 @@ async function execute(db, name, input, _ctx) {
60
57
  if (a.points_balance)
61
58
  meta.push(`${a.points_balance} pts`);
62
59
  const metaStr = meta.length ? ` [${meta.join(" · ")}]` : "";
63
- return `${a.id} | ${sanitizeForPromptCell(a.name)} | ${a.type}${a.subtype ? `/${a.subtype}` : ""} | balance ${formatTHB(a.balance)}${metaStr}`;
60
+ return `${a.id} | ${sanitizeForPromptCell(a.name)} | ${a.type}${a.subtype ? `/${a.subtype}` : ""} | balance ${formatAmount(a.balance)}${metaStr}`;
64
61
  })
65
62
  .join("\n");
66
63
  }
@@ -1,23 +1,44 @@
1
1
  import { commonTools } from "./common.js";
2
2
  import { readTools } from "./read.js";
3
- import { ingestTools } from "./ingest.js";
3
+ import { accountIngestTools, scanConcernTools, reviewIngestTools } from "./ingest.js";
4
4
  import { scanTools } from "./scan.js";
5
- import { reconcileTools } from "./reconcile.js";
5
+ import { reviewTools } from "./review.js";
6
+ import { recordTools } from "./record.js";
7
+ import { merchantTools } from "./merchants.js";
6
8
  /**
7
9
  * Profile composition. Each profile is the union of one or more tool modules;
8
10
  * the dispatcher iterates every module on each tool call so we never need a
9
11
  * central switch.
12
+ *
13
+ * `accountIngestTools` (create_account / update_account_metadata /
14
+ * record_transaction) ships with scan, review, and record — they're the
15
+ * shared write primitives. `scanConcernTools` (note_concern) is scan-only;
16
+ * record uses `clarify` from `recordTools` for transient prompts, review uses
17
+ * `ask_user` from `reviewIngestTools` for resolve-in-place clarifications.
18
+ * `merchantTools` ships with scan, review, and record so any write profile can
19
+ * upsert / look up / re-cache merchants alongside the posting flow.
10
20
  */
11
21
  const PROFILES = {
12
- scan: [commonTools, ingestTools, scanTools],
22
+ scan: [commonTools, accountIngestTools, scanConcernTools, scanTools, merchantTools],
13
23
  chat: [commonTools, readTools],
14
- reconcile: [commonTools, readTools, ingestTools, reconcileTools],
24
+ review: [commonTools, readTools, accountIngestTools, reviewIngestTools, reviewTools, merchantTools],
25
+ record: [commonTools, readTools, accountIngestTools, recordTools, merchantTools],
15
26
  };
16
27
  export function getToolDefinitions(profile) {
17
28
  return PROFILES[profile].flatMap(m => m.DEFS);
18
29
  }
19
30
  export async function executeTool(db, name, input, ctx) {
20
- for (const mod of [commonTools, readTools, ingestTools, scanTools, reconcileTools]) {
31
+ for (const mod of [
32
+ commonTools,
33
+ readTools,
34
+ accountIngestTools,
35
+ scanConcernTools,
36
+ reviewIngestTools,
37
+ scanTools,
38
+ reviewTools,
39
+ recordTools,
40
+ merchantTools,
41
+ ]) {
21
42
  const result = await mod.execute(db, name, input, ctx);
22
43
  if (result !== undefined)
23
44
  return result;
@@ -28,7 +49,11 @@ export async function executeTool(db, name, input, ctx) {
28
49
  export const TOOL_LABELS = {
29
50
  ...commonTools.LABELS,
30
51
  ...readTools.LABELS,
31
- ...ingestTools.LABELS,
52
+ ...accountIngestTools.LABELS,
53
+ ...scanConcernTools.LABELS,
54
+ ...reviewIngestTools.LABELS,
32
55
  ...scanTools.LABELS,
33
- ...reconcileTools.LABELS,
56
+ ...reviewTools.LABELS,
57
+ ...recordTools.LABELS,
58
+ ...merchantTools.LABELS,
34
59
  };
@@ -1,2 +1,4 @@
1
1
  import type { ToolModule } from "./types.js";
2
- export declare const ingestTools: ToolModule;
2
+ export declare const accountIngestTools: ToolModule;
3
+ export declare const scanConcernTools: ToolModule;
4
+ export declare const reviewIngestTools: ToolModule;