@bbookmind/mcp 1.0.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/.claude-plugin/marketplace.json +27 -0
- package/.claude-plugin/plugin.json +18 -0
- package/.env.example +15 -0
- package/.github/workflows/validate.yml +55 -0
- package/CEO_QUICKSTART.md +161 -0
- package/CLAUDE.md +30 -0
- package/CNAME +1 -0
- package/LAUNCH_CONTENT.md +210 -0
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +283 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/db.d.ts +6 -0
- package/dist/lib/db.d.ts.map +1 -0
- package/dist/lib/db.js +209 -0
- package/dist/lib/db.js.map +1 -0
- package/dist/lib/llm.d.ts +14 -0
- package/dist/lib/llm.d.ts.map +1 -0
- package/dist/lib/llm.js +110 -0
- package/dist/lib/llm.js.map +1 -0
- package/dist/lib/qbo.d.ts +35 -0
- package/dist/lib/qbo.d.ts.map +1 -0
- package/dist/lib/qbo.js +157 -0
- package/dist/lib/qbo.js.map +1 -0
- package/dist/tools/anomalies.d.ts +3 -0
- package/dist/tools/anomalies.d.ts.map +1 -0
- package/dist/tools/anomalies.js +142 -0
- package/dist/tools/anomalies.js.map +1 -0
- package/dist/tools/approveCategories.d.ts +3 -0
- package/dist/tools/approveCategories.d.ts.map +1 -0
- package/dist/tools/approveCategories.js +41 -0
- package/dist/tools/approveCategories.js.map +1 -0
- package/dist/tools/categorize.d.ts +3 -0
- package/dist/tools/categorize.d.ts.map +1 -0
- package/dist/tools/categorize.js +160 -0
- package/dist/tools/categorize.js.map +1 -0
- package/dist/tools/chaseEmail.d.ts +3 -0
- package/dist/tools/chaseEmail.d.ts.map +1 -0
- package/dist/tools/chaseEmail.js +29 -0
- package/dist/tools/chaseEmail.js.map +1 -0
- package/dist/tools/fetchTransactions.d.ts +3 -0
- package/dist/tools/fetchTransactions.d.ts.map +1 -0
- package/dist/tools/fetchTransactions.js +72 -0
- package/dist/tools/fetchTransactions.js.map +1 -0
- package/dist/tools/learnRules.d.ts +3 -0
- package/dist/tools/learnRules.d.ts.map +1 -0
- package/dist/tools/learnRules.js +72 -0
- package/dist/tools/learnRules.js.map +1 -0
- package/dist/tools/listCompanies.d.ts +3 -0
- package/dist/tools/listCompanies.d.ts.map +1 -0
- package/dist/tools/listCompanies.js +26 -0
- package/dist/tools/listCompanies.js.map +1 -0
- package/dist/tools/monthlyClose.d.ts +3 -0
- package/dist/tools/monthlyClose.d.ts.map +1 -0
- package/dist/tools/monthlyClose.js +92 -0
- package/dist/tools/monthlyClose.js.map +1 -0
- package/dist/tools/reconcile.d.ts +3 -0
- package/dist/tools/reconcile.d.ts.map +1 -0
- package/dist/tools/reconcile.js +86 -0
- package/dist/tools/reconcile.js.map +1 -0
- package/dist/tools/rejectCategories.d.ts +3 -0
- package/dist/tools/rejectCategories.d.ts.map +1 -0
- package/dist/tools/rejectCategories.js +36 -0
- package/dist/tools/rejectCategories.js.map +1 -0
- package/dist/tools/selectCompany.d.ts +3 -0
- package/dist/tools/selectCompany.d.ts.map +1 -0
- package/dist/tools/selectCompany.js +28 -0
- package/dist/tools/selectCompany.js.map +1 -0
- package/dist/tools/syncToQbo.d.ts +3 -0
- package/dist/tools/syncToQbo.d.ts.map +1 -0
- package/dist/tools/syncToQbo.js +53 -0
- package/dist/tools/syncToQbo.js.map +1 -0
- package/dist/tools/undoCategories.d.ts +3 -0
- package/dist/tools/undoCategories.d.ts.map +1 -0
- package/dist/tools/undoCategories.js +50 -0
- package/dist/tools/undoCategories.js.map +1 -0
- package/dist/tools/vendorSearch.d.ts +3 -0
- package/dist/tools/vendorSearch.d.ts.map +1 -0
- package/dist/tools/vendorSearch.js +39 -0
- package/dist/tools/vendorSearch.js.map +1 -0
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/index.html +403 -0
- package/install/claude-desktop.json +14 -0
- package/install/cline.json +16 -0
- package/install/continue.json +14 -0
- package/install/cursor.json +14 -0
- package/install/goose.yaml +10 -0
- package/install/lm-studio.json +14 -0
- package/install/vscode-copilot.json +14 -0
- package/install/windsurf.json +14 -0
- package/mcp-server.toml +30 -0
- package/package.json +49 -0
- package/server.json +136 -0
- package/src/index.ts +313 -0
- package/src/lib/db.ts +212 -0
- package/src/lib/llm.ts +140 -0
- package/src/lib/qbo.ts +187 -0
- package/src/tools/anomalies.ts +144 -0
- package/src/tools/approveCategories.ts +46 -0
- package/src/tools/categorize.ts +166 -0
- package/src/tools/chaseEmail.ts +30 -0
- package/src/tools/fetchTransactions.ts +79 -0
- package/src/tools/learnRules.ts +71 -0
- package/src/tools/listCompanies.ts +30 -0
- package/src/tools/monthlyClose.ts +95 -0
- package/src/tools/reconcile.ts +86 -0
- package/src/tools/rejectCategories.ts +43 -0
- package/src/tools/selectCompany.ts +33 -0
- package/src/tools/syncToQbo.ts +56 -0
- package/src/tools/undoCategories.ts +50 -0
- package/src/tools/vendorSearch.ts +32 -0
- package/src/types.ts +11 -0
- package/tsconfig.json +23 -0
package/src/lib/db.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import initSqlJs, { Database } from "sql.js";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { dirname } from "path";
|
|
5
|
+
import { promises as fs } from "fs";
|
|
6
|
+
import { activeCompanyId } from "../types.js";
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
const DATA_DIR = resolve(__dirname, "..", "..", "data");
|
|
11
|
+
|
|
12
|
+
let sqlJs: any = null;
|
|
13
|
+
let dbInstances: Record<string, Database> = {};
|
|
14
|
+
|
|
15
|
+
function dbPath(companyId: string): string {
|
|
16
|
+
return resolve(DATA_DIR, `bookmind-${companyId}.sqlite`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function getDb(): Promise<Database> {
|
|
20
|
+
const cid = activeCompanyId.current || "default";
|
|
21
|
+
if (dbInstances[cid]) return dbInstances[cid];
|
|
22
|
+
|
|
23
|
+
if (!sqlJs) {
|
|
24
|
+
sqlJs = await initSqlJs({ locateFile: () => "sql-wasm.wasm" });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const path = dbPath(cid);
|
|
28
|
+
try {
|
|
29
|
+
const data = await fs.readFile(path);
|
|
30
|
+
const uint8Array = new Uint8Array(data);
|
|
31
|
+
dbInstances[cid] = new sqlJs.Database(uint8Array);
|
|
32
|
+
} catch {
|
|
33
|
+
dbInstances[cid] = new sqlJs.Database();
|
|
34
|
+
}
|
|
35
|
+
dbInstances[cid].exec("PRAGMA journal_mode=WAL;");
|
|
36
|
+
dbInstances[cid].exec("PRAGMA synchronous=NORMAL;");
|
|
37
|
+
initSchema(dbInstances[cid]);
|
|
38
|
+
await saveDb();
|
|
39
|
+
return dbInstances[cid];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getCurrentCompanyId(): string {
|
|
43
|
+
return activeCompanyId.current || "default";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function initSchema(db: Database) {
|
|
47
|
+
db.exec(`
|
|
48
|
+
CREATE TABLE IF NOT EXISTS companies (
|
|
49
|
+
id TEXT PRIMARY KEY,
|
|
50
|
+
name TEXT NOT NULL,
|
|
51
|
+
realm_id TEXT,
|
|
52
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
CREATE TABLE IF NOT EXISTS qbo_tokens (
|
|
56
|
+
id TEXT PRIMARY KEY,
|
|
57
|
+
company_id TEXT NOT NULL,
|
|
58
|
+
access_token TEXT NOT NULL,
|
|
59
|
+
refresh_token TEXT NOT NULL,
|
|
60
|
+
realm_id TEXT NOT NULL,
|
|
61
|
+
expires_at INTEGER NOT NULL,
|
|
62
|
+
token_type TEXT,
|
|
63
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
CREATE TABLE IF NOT EXISTS transactions (
|
|
67
|
+
id TEXT PRIMARY KEY,
|
|
68
|
+
company_id TEXT DEFAULT 'default',
|
|
69
|
+
qbo_id TEXT,
|
|
70
|
+
date TEXT NOT NULL,
|
|
71
|
+
amount REAL NOT NULL,
|
|
72
|
+
description TEXT,
|
|
73
|
+
vendor TEXT,
|
|
74
|
+
account_id TEXT,
|
|
75
|
+
category TEXT,
|
|
76
|
+
confidence REAL,
|
|
77
|
+
status TEXT DEFAULT 'uncategorized',
|
|
78
|
+
approved INTEGER DEFAULT 0,
|
|
79
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
80
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
CREATE TABLE IF NOT EXISTS categorization_rules (
|
|
84
|
+
id TEXT PRIMARY KEY,
|
|
85
|
+
company_id TEXT DEFAULT 'default',
|
|
86
|
+
vendor_pattern TEXT,
|
|
87
|
+
description_pattern TEXT,
|
|
88
|
+
category TEXT NOT NULL,
|
|
89
|
+
account_id TEXT,
|
|
90
|
+
confidence REAL DEFAULT 0.9,
|
|
91
|
+
priority INTEGER DEFAULT 0,
|
|
92
|
+
uses INTEGER DEFAULT 0,
|
|
93
|
+
overrides INTEGER DEFAULT 0,
|
|
94
|
+
last_applied TEXT,
|
|
95
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
96
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
CREATE TABLE IF NOT EXISTS anomalies (
|
|
100
|
+
id TEXT PRIMARY KEY,
|
|
101
|
+
company_id TEXT DEFAULT 'default',
|
|
102
|
+
type TEXT NOT NULL,
|
|
103
|
+
severity TEXT NOT NULL,
|
|
104
|
+
description TEXT,
|
|
105
|
+
transaction_id TEXT,
|
|
106
|
+
vendor TEXT,
|
|
107
|
+
amount REAL,
|
|
108
|
+
detected_at TEXT DEFAULT (datetime('now')),
|
|
109
|
+
acknowledged INTEGER DEFAULT 0
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
CREATE TABLE IF NOT EXISTS invoices (
|
|
113
|
+
id TEXT PRIMARY KEY,
|
|
114
|
+
company_id TEXT DEFAULT 'default',
|
|
115
|
+
qbo_id TEXT UNIQUE,
|
|
116
|
+
customer_id TEXT,
|
|
117
|
+
customer_name TEXT,
|
|
118
|
+
customer_email TEXT,
|
|
119
|
+
amount REAL,
|
|
120
|
+
due_date TEXT,
|
|
121
|
+
status TEXT,
|
|
122
|
+
invoice_number TEXT,
|
|
123
|
+
balance REAL,
|
|
124
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
CREATE TABLE IF NOT EXISTS bills (
|
|
128
|
+
id TEXT PRIMARY KEY,
|
|
129
|
+
company_id TEXT DEFAULT 'default',
|
|
130
|
+
qbo_id TEXT UNIQUE,
|
|
131
|
+
vendor_id TEXT,
|
|
132
|
+
vendor_name TEXT,
|
|
133
|
+
amount REAL,
|
|
134
|
+
due_date TEXT,
|
|
135
|
+
status TEXT,
|
|
136
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
140
|
+
id TEXT PRIMARY KEY,
|
|
141
|
+
company_id TEXT DEFAULT 'default',
|
|
142
|
+
entity_type TEXT NOT NULL,
|
|
143
|
+
entity_id TEXT NOT NULL,
|
|
144
|
+
field TEXT NOT NULL,
|
|
145
|
+
old_value TEXT,
|
|
146
|
+
new_value TEXT,
|
|
147
|
+
source TEXT,
|
|
148
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
CREATE TABLE IF NOT EXISTS license_keys (
|
|
152
|
+
id TEXT PRIMARY KEY,
|
|
153
|
+
key_hash TEXT UNIQUE NOT NULL,
|
|
154
|
+
tier TEXT NOT NULL,
|
|
155
|
+
email TEXT,
|
|
156
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
157
|
+
expires_at TEXT,
|
|
158
|
+
active INTEGER DEFAULT 1
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_companies_name ON companies(name);
|
|
162
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_qbo_tokens_company ON qbo_tokens(company_id);
|
|
163
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_qbo ON transactions(company_id, qbo_id);
|
|
164
|
+
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(company_id, date);
|
|
165
|
+
CREATE INDEX IF NOT EXISTS idx_transactions_vendor ON transactions(company_id, vendor);
|
|
166
|
+
CREATE INDEX IF NOT EXISTS idx_transactions_status ON transactions(company_id, status);
|
|
167
|
+
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(company_id, category);
|
|
168
|
+
CREATE INDEX IF NOT EXISTS idx_transactions_approved ON transactions(company_id, approved);
|
|
169
|
+
CREATE INDEX IF NOT EXISTS idx_rules_company ON categorization_rules(company_id);
|
|
170
|
+
CREATE INDEX IF NOT EXISTS idx_rules_vendor ON categorization_rules(vendor_pattern);
|
|
171
|
+
CREATE INDEX IF NOT EXISTS idx_rules_priority ON categorization_rules(priority);
|
|
172
|
+
CREATE INDEX IF NOT EXISTS idx_anomalies_type ON anomalies(company_id, type);
|
|
173
|
+
CREATE INDEX IF NOT EXISTS idx_anomalies_severity ON anomalies(company_id, severity);
|
|
174
|
+
CREATE INDEX IF NOT EXISTS idx_invoices_company ON invoices(company_id);
|
|
175
|
+
CREATE INDEX IF NOT EXISTS idx_bills_company ON bills(company_id);
|
|
176
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log(company_id, entity_type, entity_id);
|
|
177
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at);
|
|
178
|
+
`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function saveDb(): Promise<void> {
|
|
182
|
+
const cid = activeCompanyId.current || "default";
|
|
183
|
+
if (dbInstances[cid] && sqlJs) {
|
|
184
|
+
const data = dbInstances[cid].export();
|
|
185
|
+
const dir = DATA_DIR;
|
|
186
|
+
await fs.mkdir(dir, { recursive: true });
|
|
187
|
+
await fs.writeFile(dbPath(cid), Buffer.from(data));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function auditChange(db: Database, entityType: string, entityId: string, field: string, oldValue: string | null, newValue: string, source: string) {
|
|
192
|
+
const cid = activeCompanyId.current || "default";
|
|
193
|
+
const id = crypto.randomUUID();
|
|
194
|
+
db.exec(`INSERT INTO audit_log (id, company_id, entity_type, entity_id, field, old_value, new_value, source) VALUES ('${id}', '${cid}', '${entityType}', '${entityId}', '${field}', ${oldValue !== null ? `'${oldValue.replace(/'/g, "''")}'` : "NULL"}, '${newValue.replace(/'/g, "''")}', '${source}')`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
setInterval(async () => { await saveDb(); }, 30000);
|
|
198
|
+
|
|
199
|
+
process.on("SIGINT", async () => {
|
|
200
|
+
for (const [, db] of Object.entries(dbInstances)) {
|
|
201
|
+
const data = db.export();
|
|
202
|
+
try { await fs.writeFile(dbPath(""), Buffer.from(data)); } catch {}
|
|
203
|
+
}
|
|
204
|
+
process.exit(0);
|
|
205
|
+
});
|
|
206
|
+
process.on("SIGTERM", async () => {
|
|
207
|
+
for (const [, db] of Object.entries(dbInstances)) {
|
|
208
|
+
const data = db.export();
|
|
209
|
+
try { await fs.writeFile(dbPath(""), Buffer.from(data)); } catch {}
|
|
210
|
+
}
|
|
211
|
+
process.exit(0);
|
|
212
|
+
});
|
package/src/lib/llm.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
|
|
3
|
+
export interface LLMConfig {
|
|
4
|
+
provider: "groq" | "nvidia" | "google" | "mistral" | "openai" | "anthropic" | "ollama";
|
|
5
|
+
baseUrl?: string;
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
model: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const PROVIDERS: Record<string, { key: string; url: string; model: string }> = {
|
|
11
|
+
groq: { key: "GROQ_API_KEY", url: "https://api.groq.com/openai/v1", model: "llama-3.3-70b-versatile" },
|
|
12
|
+
nvidia: { key: "NVIDIA_API_KEY", url: "https://integrate.api.nvidia.com/v1", model: "meta/llama-3.1-70b-instruct" },
|
|
13
|
+
google: { key: "GOOGLE_API_KEY", url: "https://generativelanguage.googleapis.com/v1beta", model: "gemini-1.5-flash" },
|
|
14
|
+
mistral: { key: "MISTRAL_API_KEY", url: "https://api.mistral.ai/v1", model: "mistral-small-latest" },
|
|
15
|
+
openai: { key: "OPENAI_API_KEY", url: "https://api.openai.com/v1", model: "gpt-4o-mini" },
|
|
16
|
+
anthropic: { key: "ANTHROPIC_API_KEY", url: "https://api.anthropic.com/v1", model: "claude-3-5-sonnet-20241022" },
|
|
17
|
+
ollama: { key: "", url: "http://localhost:11434", model: "llama3.2" },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
let activeProvider: LLMConfig | null = null;
|
|
21
|
+
|
|
22
|
+
function detectProvider(): LLMConfig | null {
|
|
23
|
+
for (const [name, cfg] of Object.entries(PROVIDERS)) {
|
|
24
|
+
if (cfg.key && process.env[cfg.key]) {
|
|
25
|
+
return {
|
|
26
|
+
provider: name as any,
|
|
27
|
+
baseUrl: process.env[`${cfg.key.replace("_API_KEY", "_BASE_URL")}`] || cfg.url,
|
|
28
|
+
apiKey: process.env[cfg.key],
|
|
29
|
+
model: process.env[`${cfg.key.replace("_API_KEY", "_MODEL")}`] || cfg.model,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (process.env.OLLAMA_BASE_URL) {
|
|
34
|
+
return {
|
|
35
|
+
provider: "ollama",
|
|
36
|
+
baseUrl: process.env.OLLAMA_BASE_URL,
|
|
37
|
+
model: process.env.OLLAMA_MODEL || PROVIDERS.ollama.model,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getProvider(): LLMConfig | null {
|
|
44
|
+
if (!activeProvider) activeProvider = detectProvider();
|
|
45
|
+
return activeProvider;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isLLMEnabled(): boolean {
|
|
49
|
+
return getProvider() !== null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function callLLM(prompt: string, systemPrompt?: string): Promise<string> {
|
|
53
|
+
const cfg = getProvider();
|
|
54
|
+
if (!cfg) throw new Error("No AI provider configured");
|
|
55
|
+
|
|
56
|
+
const messages = [
|
|
57
|
+
...(systemPrompt ? [{ role: "system", content: systemPrompt }] : []),
|
|
58
|
+
{ role: "user", content: prompt },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
if (cfg.provider === "anthropic") {
|
|
63
|
+
const res = await axios.post(
|
|
64
|
+
`${cfg.baseUrl}/messages`,
|
|
65
|
+
{
|
|
66
|
+
model: cfg.model,
|
|
67
|
+
messages: messages.filter((m) => m.role !== "system"),
|
|
68
|
+
system: systemPrompt,
|
|
69
|
+
max_tokens: 2000,
|
|
70
|
+
temperature: 0.1,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
headers: {
|
|
74
|
+
"x-api-key": cfg.apiKey!,
|
|
75
|
+
"anthropic-version": "2023-06-01",
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
return res.data.content?.[0]?.text || "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (cfg.provider === "google") {
|
|
84
|
+
const res = await axios.post(
|
|
85
|
+
`${cfg.baseUrl}/models/${cfg.model}:generateContent?key=${cfg.apiKey}`,
|
|
86
|
+
{
|
|
87
|
+
contents: messages.map((m) => ({ role: m.role === "system" ? "user" : m.role, parts: [{ text: m.content }] })),
|
|
88
|
+
generationConfig: { temperature: 0.1, maxOutputTokens: 2000 },
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
return res.data.candidates?.[0]?.content?.parts?.[0]?.text || "";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const res = await axios.post(
|
|
95
|
+
`${cfg.baseUrl}/chat/completions`,
|
|
96
|
+
{
|
|
97
|
+
model: cfg.model,
|
|
98
|
+
messages,
|
|
99
|
+
temperature: 0.1,
|
|
100
|
+
max_tokens: 2000,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
headers: {
|
|
104
|
+
Authorization: `Bearer ${cfg.apiKey}`,
|
|
105
|
+
"Content-Type": "application/json",
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
return res.data.choices?.[0]?.message?.content || "";
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error("LLM call failed:", error);
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function categorizeTransaction(
|
|
117
|
+
description: string,
|
|
118
|
+
vendor: string,
|
|
119
|
+
amount: number,
|
|
120
|
+
knownCategories: string[]
|
|
121
|
+
): Promise<{ category: string; confidence: number }> {
|
|
122
|
+
const cfg = getProvider();
|
|
123
|
+
if (!cfg) return { category: "Uncategorized Expenses", confidence: 0.3 };
|
|
124
|
+
|
|
125
|
+
const prompt = `Categorize this transaction:
|
|
126
|
+
Description: "${description}"
|
|
127
|
+
Vendor: "${vendor}"
|
|
128
|
+
Amount: $${amount}
|
|
129
|
+
|
|
130
|
+
Available categories: ${knownCategories.join(", ")}
|
|
131
|
+
|
|
132
|
+
Return JSON: {"category": "...", "confidence": 0.0-1.0}`;
|
|
133
|
+
|
|
134
|
+
const result = await callLLM(prompt, "You are an expert bookkeeper. Categorize accurately.");
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(result);
|
|
137
|
+
} catch {
|
|
138
|
+
return { category: "Uncategorized Expenses", confidence: 0.3 };
|
|
139
|
+
}
|
|
140
|
+
}
|
package/src/lib/qbo.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { dirname } from "path";
|
|
5
|
+
import { promises as fs } from "fs";
|
|
6
|
+
import { getDb } from "./db.js";
|
|
7
|
+
import { activeCompanyId } from "../types.js";
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = dirname(__filename);
|
|
11
|
+
const TOKEN_DIR = resolve(__dirname, "..", "..", "data");
|
|
12
|
+
|
|
13
|
+
export interface QBOAuthTokens {
|
|
14
|
+
access_token: string;
|
|
15
|
+
refresh_token: string;
|
|
16
|
+
realm_id: string;
|
|
17
|
+
expires_at: number;
|
|
18
|
+
token_type: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function escapeQuery(value: string): string {
|
|
22
|
+
return value.replace(/'/g, "\\'");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class QBOClient {
|
|
26
|
+
private tokens: QBOAuthTokens | null = null;
|
|
27
|
+
private clientId: string;
|
|
28
|
+
private clientSecret: string;
|
|
29
|
+
private redirectUri: string;
|
|
30
|
+
private environment: "sandbox" | "production";
|
|
31
|
+
private baseUrl: string;
|
|
32
|
+
|
|
33
|
+
constructor() {
|
|
34
|
+
this.clientId = process.env.QBO_CLIENT_ID || "";
|
|
35
|
+
this.clientSecret = process.env.QBO_CLIENT_SECRET || "";
|
|
36
|
+
this.redirectUri = process.env.QBO_REDIRECT_URI || "http://localhost:3000/callback";
|
|
37
|
+
this.environment = (process.env.QBO_ENVIRONMENT as "sandbox" | "production") || "sandbox";
|
|
38
|
+
this.baseUrl = this.environment === "sandbox"
|
|
39
|
+
? "https://sandbox-quickbooks.api.intuit.com"
|
|
40
|
+
: "https://quickbooks.api.intuit.com";
|
|
41
|
+
this.loadTokens();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private tokenFilePath(): string {
|
|
45
|
+
const cid = activeCompanyId.current || "default";
|
|
46
|
+
return resolve(TOKEN_DIR, `qbo_tokens_${cid}.json`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private async loadTokens() {
|
|
50
|
+
try {
|
|
51
|
+
const data = await fs.readFile(this.tokenFilePath(), "utf-8");
|
|
52
|
+
this.tokens = JSON.parse(data);
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private async saveTokens() {
|
|
57
|
+
await fs.mkdir(TOKEN_DIR, { recursive: true });
|
|
58
|
+
await fs.writeFile(this.tokenFilePath(), JSON.stringify(this.tokens, null, 2));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
getAuthUrl(): string {
|
|
62
|
+
const scopes = ["com.intuit.quickbooks.accounting"].join(" ");
|
|
63
|
+
const params = new URLSearchParams({
|
|
64
|
+
client_id: this.clientId,
|
|
65
|
+
redirect_uri: this.redirectUri,
|
|
66
|
+
scope: scopes,
|
|
67
|
+
response_type: "code",
|
|
68
|
+
access_type: "offline",
|
|
69
|
+
state: "bookmind-" + (activeCompanyId.current || "default"),
|
|
70
|
+
});
|
|
71
|
+
return `https://appcenter.intuit.com/connect/oauth2?${params.toString()}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async handleCallback(code: string): Promise<QBOAuthTokens> {
|
|
75
|
+
const res = await axios.post("https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer",
|
|
76
|
+
new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: this.redirectUri }),
|
|
77
|
+
{ headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64")}` } }
|
|
78
|
+
);
|
|
79
|
+
this.tokens = {
|
|
80
|
+
access_token: res.data.access_token,
|
|
81
|
+
refresh_token: res.data.refresh_token,
|
|
82
|
+
realm_id: res.data.realmId,
|
|
83
|
+
expires_at: Date.now() + res.data.expires_in * 1000,
|
|
84
|
+
token_type: res.data.token_type,
|
|
85
|
+
};
|
|
86
|
+
await this.saveTokens();
|
|
87
|
+
const db = await getDb();
|
|
88
|
+
const rows = db.exec(`SELECT 1 FROM companies WHERE id='${activeCompanyId.current || "default"}'`);
|
|
89
|
+
if (!rows.length) {
|
|
90
|
+
db.exec(`INSERT INTO companies (id, name, realm_id) VALUES ('${activeCompanyId.current || "default"}', '${activeCompanyId.current || "Default"}', '${this.tokens.realm_id}')`);
|
|
91
|
+
} else {
|
|
92
|
+
db.run(`UPDATE companies SET realm_id=? WHERE id=?`, [this.tokens.realm_id, activeCompanyId.current || "default"] as any);
|
|
93
|
+
}
|
|
94
|
+
return this.tokens;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async refreshAccessToken(): Promise<string> {
|
|
98
|
+
if (!this.tokens?.refresh_token) throw new Error("QBO_NOT_AUTHENTICATED");
|
|
99
|
+
const res = await axios.post("https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer",
|
|
100
|
+
new URLSearchParams({ grant_type: "refresh_token", refresh_token: this.tokens.refresh_token }),
|
|
101
|
+
{ headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64")}` } }
|
|
102
|
+
);
|
|
103
|
+
this.tokens = {
|
|
104
|
+
...this.tokens!,
|
|
105
|
+
access_token: res.data.access_token,
|
|
106
|
+
refresh_token: res.data.refresh_token || this.tokens!.refresh_token,
|
|
107
|
+
expires_at: Date.now() + res.data.expires_in * 1000,
|
|
108
|
+
};
|
|
109
|
+
await this.saveTokens();
|
|
110
|
+
return this.tokens.access_token;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async getValidToken(): Promise<string> {
|
|
114
|
+
if (!this.tokens) throw new Error("QBO_NOT_AUTHENTICATED");
|
|
115
|
+
if (Date.now() >= this.tokens.expires_at - 300000) {
|
|
116
|
+
return this.refreshAccessToken();
|
|
117
|
+
}
|
|
118
|
+
return this.tokens.access_token;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
isAuthenticated(): boolean {
|
|
122
|
+
return this.tokens !== null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async request<T>(method: string, endpoint: string, data?: any, attempt = 0): Promise<T> {
|
|
126
|
+
const token = await this.getValidToken();
|
|
127
|
+
const url = `${this.baseUrl}/v3/company/${this.tokens!.realm_id}${endpoint}`;
|
|
128
|
+
try {
|
|
129
|
+
const response = await axios.request<T>({
|
|
130
|
+
method, url, data,
|
|
131
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
|
|
132
|
+
});
|
|
133
|
+
return response.data;
|
|
134
|
+
} catch (error: any) {
|
|
135
|
+
if (error.response?.status === 429 && attempt < 3) {
|
|
136
|
+
const delay = Math.pow(2, attempt) * 1000;
|
|
137
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
138
|
+
return this.request<T>(method, endpoint, data, attempt + 1);
|
|
139
|
+
}
|
|
140
|
+
if (error.response?.status === 401 && attempt < 1) {
|
|
141
|
+
await this.refreshAccessToken();
|
|
142
|
+
return this.request<T>(method, endpoint, data, attempt + 1);
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async getTransactions(startDate: string, endDate: string, maxResults = 500) {
|
|
149
|
+
const sd = escapeQuery(startDate);
|
|
150
|
+
const ed = escapeQuery(endDate);
|
|
151
|
+
const query = `SELECT * FROM Transaction WHERE TxnDate >= '${sd}' AND TxnDate <= '${ed}' ORDER BY TxnDate MAXRESULTS ${maxResults}`;
|
|
152
|
+
return this.request("POST", "/query", { query });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async getInvoices(status?: string) {
|
|
156
|
+
const query = status ? `SELECT * FROM Invoice WHERE DocStatus = '${escapeQuery(status)}'` : "SELECT * FROM Invoice";
|
|
157
|
+
return this.request("POST", "/query", { query });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async getBills(status?: string) {
|
|
161
|
+
const query = status ? `SELECT * FROM Bill WHERE DocStatus = '${escapeQuery(status)}'` : "SELECT * FROM Bill";
|
|
162
|
+
return this.request("POST", "/query", { query });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async getAccounts() {
|
|
166
|
+
return this.request("POST", "/query", { query: "SELECT * FROM Account" });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async getVendors() {
|
|
170
|
+
return this.request("POST", "/query", { query: "SELECT * FROM Vendor" });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async getCustomers() {
|
|
174
|
+
return this.request("POST", "/query", { query: "SELECT * FROM Customer" });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async updateTransaction(transactionId: string, sparse: Record<string, any>) {
|
|
178
|
+
const body = { sparse, Id: transactionId, SyncToken: "0" };
|
|
179
|
+
return this.request("POST", "/transaction", body);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async createJournalEntry(entry: any) {
|
|
183
|
+
return this.request("POST", "/journalentry", entry);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export const qboClient = new QBOClient();
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { ToolDefinition } from "../types.js";
|
|
2
|
+
import { Database } from "sql.js";
|
|
3
|
+
import { getCurrentCompanyId } from "../lib/db.js";
|
|
4
|
+
|
|
5
|
+
export const anomaliesTool: ToolDefinition = {
|
|
6
|
+
name: "bookmind.anomalies",
|
|
7
|
+
description:
|
|
8
|
+
"Statistical anomaly detection: duplicate payments (Map-based O(n)), vendor cost drift (>25%), IQR outliers, frequency spikes (>10/mo), round numbers (>=$100), large weekend transactions (>=$500). Returns flagged items with severity. Persists to SQLite for review.",
|
|
9
|
+
schema: {
|
|
10
|
+
type: "object",
|
|
11
|
+
properties: {
|
|
12
|
+
transactions: {
|
|
13
|
+
type: "array",
|
|
14
|
+
items: {
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: { id: { type: "string" }, date: { type: "string" }, amount: { type: "number" }, description: { type: "string" }, vendor: { type: "string" }, category: { type: "string" } },
|
|
17
|
+
required: ["id", "date", "amount", "vendor"],
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
lookback_days: { type: "number", default: 90 },
|
|
21
|
+
min_amount: { type: "number", default: 10 },
|
|
22
|
+
rules: {
|
|
23
|
+
type: "array",
|
|
24
|
+
items: { type: "string", enum: ["duplicates", "vendor_drift", "unusual_amount", "frequency", "round_numbers", "weekend"] },
|
|
25
|
+
default: ["duplicates", "vendor_drift", "unusual_amount", "frequency", "round_numbers", "weekend"],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
required: ["transactions"],
|
|
29
|
+
},
|
|
30
|
+
handler: async (args, db: Database) => {
|
|
31
|
+
const coId = getCurrentCompanyId();
|
|
32
|
+
const { transactions, lookback_days = 90, min_amount = 10, rules = ["duplicates", "vendor_drift", "unusual_amount", "frequency", "round_numbers", "weekend"] } = args as any;
|
|
33
|
+
const since = new Date(Date.now() - lookback_days * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
|
34
|
+
const historical = transactions.filter((t: any) => new Date(t.date) >= new Date(since));
|
|
35
|
+
|
|
36
|
+
const anomalies: any[] = [];
|
|
37
|
+
|
|
38
|
+
if (rules.includes("duplicates")) {
|
|
39
|
+
const groups = new Map<string, any[]>();
|
|
40
|
+
for (const t of historical) {
|
|
41
|
+
const key = `${t.vendor.toLowerCase()}|${Math.round(t.amount * 100)}`;
|
|
42
|
+
const g = groups.get(key) || [];
|
|
43
|
+
g.push(t);
|
|
44
|
+
groups.set(key, g);
|
|
45
|
+
}
|
|
46
|
+
for (const [, group] of groups) {
|
|
47
|
+
if (group.length < 2) continue;
|
|
48
|
+
for (let i = 0; i < group.length; i++) {
|
|
49
|
+
for (let j = i + 1; j < group.length; j++) {
|
|
50
|
+
const a = group[i], b = group[j];
|
|
51
|
+
const daysDiff = Math.abs(new Date(a.date).getTime() - new Date(b.date).getTime()) / (1000 * 60 * 60 * 24);
|
|
52
|
+
if (daysDiff <= 7) {
|
|
53
|
+
anomalies.push({
|
|
54
|
+
id: `dup-${a.id}-${b.id}`,
|
|
55
|
+
type: "duplicate_payment",
|
|
56
|
+
severity: "high",
|
|
57
|
+
description: `Duplicate payment to ${a.vendor}: $${a.amount} on ${a.date} and ${b.date} (${daysDiff.toFixed(1)} days apart)`,
|
|
58
|
+
transaction_ids: [a.id, b.id], vendor: a.vendor, amount: a.amount,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (rules.includes("vendor_drift")) {
|
|
67
|
+
const vendorStats: Record<string, { amounts: number[]; count: number }> = {};
|
|
68
|
+
for (const t of historical) {
|
|
69
|
+
if (!vendorStats[t.vendor]) vendorStats[t.vendor] = { amounts: [], count: 0 };
|
|
70
|
+
vendorStats[t.vendor].amounts.push(Math.abs(t.amount));
|
|
71
|
+
vendorStats[t.vendor].count++;
|
|
72
|
+
}
|
|
73
|
+
for (const [vendor, stats] of Object.entries(vendorStats)) {
|
|
74
|
+
if (stats.count < 3) continue;
|
|
75
|
+
const avg = stats.amounts.reduce((a: number, b: number) => a + b, 0) / stats.amounts.length;
|
|
76
|
+
for (const t of historical) {
|
|
77
|
+
if (t.vendor === vendor && Math.abs(t.amount) > avg * 1.25) {
|
|
78
|
+
anomalies.push({
|
|
79
|
+
id: `drift-${vendor}-${t.id}`, type: "vendor_cost_drift", severity: "medium",
|
|
80
|
+
description: `${vendor} charge $${t.amount} is ${((Math.abs(t.amount) - avg) / avg * 100).toFixed(1)}% above avg of $${avg.toFixed(2)}`,
|
|
81
|
+
transaction_id: t.id, vendor, amount: t.amount, average: avg,
|
|
82
|
+
increase_pct: ((Math.abs(t.amount) - avg) / avg * 100).toFixed(1), date: t.date,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (rules.includes("unusual_amount")) {
|
|
90
|
+
const amts = historical.map((t: any) => Math.abs(t.amount)).sort((a: number, b: number) => a - b);
|
|
91
|
+
if (amts.length >= 4) {
|
|
92
|
+
const q1 = amts[Math.floor(amts.length * 0.25)];
|
|
93
|
+
const q3 = amts[Math.floor(amts.length * 0.75)];
|
|
94
|
+
const iqr = q3 - q1;
|
|
95
|
+
const upper = q3 + 1.5 * iqr;
|
|
96
|
+
for (const t of historical) {
|
|
97
|
+
if (Math.abs(t.amount) > upper) {
|
|
98
|
+
anomalies.push({
|
|
99
|
+
id: `outlier-${t.id}`, type: "unusual_amount", severity: "medium",
|
|
100
|
+
description: `$${t.amount} to ${t.vendor || t.description} exceeds fence ($${upper.toFixed(2)})`,
|
|
101
|
+
transaction_id: t.id, amount: t.amount, threshold: upper,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (rules.includes("frequency")) {
|
|
109
|
+
const thisMonth = new Date().toISOString().slice(0, 7);
|
|
110
|
+
const counts: Record<string, number> = {};
|
|
111
|
+
for (const t of historical) { if (t.date.startsWith(thisMonth)) counts[t.vendor] = (counts[t.vendor] || 0) + 1; }
|
|
112
|
+
for (const [v, c] of Object.entries(counts)) {
|
|
113
|
+
if (c > 10) anomalies.push({ id: `freq-${v}-${thisMonth}`, type: "high_frequency", severity: "low", description: `${v} paid ${c} times this month`, vendor: v, count: c, month: thisMonth });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (rules.includes("round_numbers")) {
|
|
118
|
+
for (const t of historical) {
|
|
119
|
+
const a = Math.abs(t.amount);
|
|
120
|
+
if (a >= 100 && a % 100 === 0) anomalies.push({ id: `round-${t.id}`, type: "round_number", severity: "low", description: `Round amount $${t.amount} to ${t.vendor || t.description}`, transaction_id: t.id, amount: t.amount });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (rules.includes("weekend")) {
|
|
125
|
+
for (const t of historical) {
|
|
126
|
+
const day = new Date(t.date).getDay();
|
|
127
|
+
if ((day === 0 || day === 6) && Math.abs(t.amount) > 500) anomalies.push({ id: `weekend-${t.id}`, type: "weekend_transaction", severity: "low", description: `Weekend $${t.amount} to ${t.vendor || t.description}`, transaction_id: t.id, amount: t.amount, day: day === 0 ? "Sunday" : "Saturday" });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const a of anomalies) {
|
|
132
|
+
const txnId = a.transaction_id || (a.transaction_ids?.[0] || "");
|
|
133
|
+
const rows = db.exec(`SELECT 1 FROM anomalies WHERE company_id='${coId}' AND transaction_id=? AND type=?`);
|
|
134
|
+
const existing = rows.filter((r: any) => r.values?.length);
|
|
135
|
+
if (!existing.length) {
|
|
136
|
+
db.exec(`INSERT INTO anomalies (id, company_id, transaction_id, type, severity, description, vendor, amount) VALUES ('${a.id.replace(/'/g, "''")}', '${coId}', '${(txnId || "").replace(/'/g, "''")}', '${(a.type || "").replace(/'/g, "''")}', '${(a.severity || "low").replace(/'/g, "''")}', '${(a.description || "").replace(/'/g, "''")}', ${a.vendor ? `'${String(a.vendor).replace(/'/g, "''")}'` : "NULL"}, ${a.amount || "NULL"})`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const bySev: Record<string, number> = {};
|
|
141
|
+
for (const a of anomalies) bySev[a.severity] = (bySev[a.severity] || 0) + 1;
|
|
142
|
+
return `Scan complete (${historical.length} txns, ${lookback_days}d). ${anomalies.length} anomalies: ${Object.entries(bySev).map(([k, v]) => `${v} ${k}`).join(", ")}.\n\n${JSON.stringify(anomalies, null, 2)}`;
|
|
143
|
+
},
|
|
144
|
+
};
|