@fuzzle/opencode-accountant 0.0.16 → 0.0.17-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -15
- package/agent/accountant.md +56 -52
- package/dist/index.js +38 -1136
- package/package.json +4 -2
package/dist/index.js
CHANGED
|
@@ -1342,28 +1342,28 @@ var require_papaparse = __commonJS((exports, module) => {
|
|
|
1342
1342
|
|
|
1343
1343
|
// node_modules/convert-csv-to-json/src/util/fileUtils.js
|
|
1344
1344
|
var require_fileUtils = __commonJS((exports, module) => {
|
|
1345
|
-
var
|
|
1345
|
+
var fs3 = __require("fs");
|
|
1346
1346
|
|
|
1347
1347
|
class FileUtils {
|
|
1348
1348
|
readFile(fileInputName, encoding) {
|
|
1349
|
-
return
|
|
1349
|
+
return fs3.readFileSync(fileInputName, encoding).toString();
|
|
1350
1350
|
}
|
|
1351
1351
|
readFileAsync(fileInputName, encoding = "utf8") {
|
|
1352
|
-
if (
|
|
1353
|
-
return
|
|
1352
|
+
if (fs3.promises && typeof fs3.promises.readFile === "function") {
|
|
1353
|
+
return fs3.promises.readFile(fileInputName, encoding).then((buf) => buf.toString());
|
|
1354
1354
|
}
|
|
1355
|
-
return new Promise((
|
|
1356
|
-
|
|
1355
|
+
return new Promise((resolve, reject) => {
|
|
1356
|
+
fs3.readFile(fileInputName, encoding, (err, data) => {
|
|
1357
1357
|
if (err) {
|
|
1358
1358
|
reject(err);
|
|
1359
1359
|
return;
|
|
1360
1360
|
}
|
|
1361
|
-
|
|
1361
|
+
resolve(data.toString());
|
|
1362
1362
|
});
|
|
1363
1363
|
});
|
|
1364
1364
|
}
|
|
1365
1365
|
writeFile(json3, fileOutputName) {
|
|
1366
|
-
|
|
1366
|
+
fs3.writeFile(fileOutputName, json3, function(err) {
|
|
1367
1367
|
if (err) {
|
|
1368
1368
|
throw err;
|
|
1369
1369
|
} else {
|
|
@@ -1372,14 +1372,14 @@ var require_fileUtils = __commonJS((exports, module) => {
|
|
|
1372
1372
|
});
|
|
1373
1373
|
}
|
|
1374
1374
|
writeFileAsync(json3, fileOutputName) {
|
|
1375
|
-
if (
|
|
1376
|
-
return
|
|
1375
|
+
if (fs3.promises && typeof fs3.promises.writeFile === "function") {
|
|
1376
|
+
return fs3.promises.writeFile(fileOutputName, json3);
|
|
1377
1377
|
}
|
|
1378
|
-
return new Promise((
|
|
1379
|
-
|
|
1378
|
+
return new Promise((resolve, reject) => {
|
|
1379
|
+
fs3.writeFile(fileOutputName, json3, (err) => {
|
|
1380
1380
|
if (err)
|
|
1381
1381
|
return reject(err);
|
|
1382
|
-
|
|
1382
|
+
resolve();
|
|
1383
1383
|
});
|
|
1384
1384
|
});
|
|
1385
1385
|
}
|
|
@@ -1800,7 +1800,7 @@ var require_browserApi = __commonJS((exports, module) => {
|
|
|
1800
1800
|
if (!file2) {
|
|
1801
1801
|
return Promise.reject(new Error("file is not defined!!!"));
|
|
1802
1802
|
}
|
|
1803
|
-
return new Promise((
|
|
1803
|
+
return new Promise((resolve, reject) => {
|
|
1804
1804
|
if (typeof FileReader === "undefined") {
|
|
1805
1805
|
reject(new Error("FileReader is not available in this environment"));
|
|
1806
1806
|
return;
|
|
@@ -1811,7 +1811,7 @@ var require_browserApi = __commonJS((exports, module) => {
|
|
|
1811
1811
|
try {
|
|
1812
1812
|
const text = reader.result;
|
|
1813
1813
|
const result = this.csvToJson.csvToJson(String(text));
|
|
1814
|
-
|
|
1814
|
+
resolve(result);
|
|
1815
1815
|
} catch (err) {
|
|
1816
1816
|
reject(err);
|
|
1817
1817
|
}
|
|
@@ -1941,7 +1941,7 @@ var require_convert_csv_to_json = __commonJS((exports) => {
|
|
|
1941
1941
|
});
|
|
1942
1942
|
|
|
1943
1943
|
// src/index.ts
|
|
1944
|
-
import { dirname
|
|
1944
|
+
import { dirname, join as join3 } from "path";
|
|
1945
1945
|
import { fileURLToPath } from "url";
|
|
1946
1946
|
|
|
1947
1947
|
// src/utils/agentLoader.ts
|
|
@@ -16981,6 +16981,21 @@ var {$ } = globalThis.Bun;
|
|
|
16981
16981
|
import * as path2 from "path";
|
|
16982
16982
|
import * as fs2 from "fs";
|
|
16983
16983
|
|
|
16984
|
+
// src/utils/agentRestriction.ts
|
|
16985
|
+
function checkAccountantAgent(agent, toolPrompt, additionalFields) {
|
|
16986
|
+
if (agent === "accountant") {
|
|
16987
|
+
return null;
|
|
16988
|
+
}
|
|
16989
|
+
const errorResponse = {
|
|
16990
|
+
success: false,
|
|
16991
|
+
error: "This tool is restricted to the accountant agent only.",
|
|
16992
|
+
hint: `Use: Task(subagent_type='accountant', prompt='${toolPrompt}')`,
|
|
16993
|
+
caller: agent || "main assistant",
|
|
16994
|
+
...additionalFields
|
|
16995
|
+
};
|
|
16996
|
+
return JSON.stringify(errorResponse);
|
|
16997
|
+
}
|
|
16998
|
+
|
|
16984
16999
|
// src/utils/pricesConfig.ts
|
|
16985
17000
|
import * as fs from "fs";
|
|
16986
17001
|
import * as path from "path";
|
|
@@ -17090,12 +17105,9 @@ function updateJournalWithPrices(journalPath, newPriceLines) {
|
|
|
17090
17105
|
`);
|
|
17091
17106
|
}
|
|
17092
17107
|
async function updatePricesCore(directory, agent, backfill, priceFetcher = defaultPriceFetcher, configLoader = loadPricesConfig) {
|
|
17093
|
-
|
|
17094
|
-
|
|
17095
|
-
|
|
17096
|
-
hint: "Use: Task(subagent_type='accountant', prompt='update prices')",
|
|
17097
|
-
caller: agent || "main assistant"
|
|
17098
|
-
});
|
|
17108
|
+
const restrictionError = checkAccountantAgent(agent, "update prices");
|
|
17109
|
+
if (restrictionError) {
|
|
17110
|
+
return restrictionError;
|
|
17099
17111
|
}
|
|
17100
17112
|
let config2;
|
|
17101
17113
|
try {
|
|
@@ -17176,1130 +17188,20 @@ var update_prices_default = tool({
|
|
|
17176
17188
|
return updatePricesCore(directory, agent, backfill || false);
|
|
17177
17189
|
}
|
|
17178
17190
|
});
|
|
17179
|
-
// src/tools/classify-statements.ts
|
|
17180
|
-
import * as path4 from "path";
|
|
17181
|
-
import * as fs4 from "fs";
|
|
17182
|
-
|
|
17183
|
-
// src/utils/importConfig.ts
|
|
17184
|
-
import * as fs3 from "fs";
|
|
17185
|
-
import * as path3 from "path";
|
|
17186
|
-
var CONFIG_FILE2 = "config/import/providers.yaml";
|
|
17187
|
-
var REQUIRED_PATH_FIELDS = [
|
|
17188
|
-
"import",
|
|
17189
|
-
"pending",
|
|
17190
|
-
"done",
|
|
17191
|
-
"unrecognized",
|
|
17192
|
-
"rules"
|
|
17193
|
-
];
|
|
17194
|
-
var REQUIRED_DETECTION_FIELDS = ["header", "currencyField"];
|
|
17195
|
-
function validatePaths(paths) {
|
|
17196
|
-
if (typeof paths !== "object" || paths === null) {
|
|
17197
|
-
throw new Error("Invalid config: 'paths' must be an object");
|
|
17198
|
-
}
|
|
17199
|
-
const pathsObj = paths;
|
|
17200
|
-
for (const field of REQUIRED_PATH_FIELDS) {
|
|
17201
|
-
if (typeof pathsObj[field] !== "string" || pathsObj[field] === "") {
|
|
17202
|
-
throw new Error(`Invalid config: 'paths.${field}' is required`);
|
|
17203
|
-
}
|
|
17204
|
-
}
|
|
17205
|
-
return {
|
|
17206
|
-
import: pathsObj.import,
|
|
17207
|
-
pending: pathsObj.pending,
|
|
17208
|
-
done: pathsObj.done,
|
|
17209
|
-
unrecognized: pathsObj.unrecognized,
|
|
17210
|
-
rules: pathsObj.rules
|
|
17211
|
-
};
|
|
17212
|
-
}
|
|
17213
|
-
function validateDetectionRule(providerName, index, rule) {
|
|
17214
|
-
if (typeof rule !== "object" || rule === null) {
|
|
17215
|
-
throw new Error(`Invalid config: provider '${providerName}' detect[${index}] must be an object`);
|
|
17216
|
-
}
|
|
17217
|
-
const ruleObj = rule;
|
|
17218
|
-
for (const field of REQUIRED_DETECTION_FIELDS) {
|
|
17219
|
-
if (typeof ruleObj[field] !== "string" || ruleObj[field] === "") {
|
|
17220
|
-
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].${field} is required`);
|
|
17221
|
-
}
|
|
17222
|
-
}
|
|
17223
|
-
if (ruleObj.filenamePattern !== undefined) {
|
|
17224
|
-
if (typeof ruleObj.filenamePattern !== "string") {
|
|
17225
|
-
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].filenamePattern must be a string`);
|
|
17226
|
-
}
|
|
17227
|
-
try {
|
|
17228
|
-
new RegExp(ruleObj.filenamePattern);
|
|
17229
|
-
} catch {
|
|
17230
|
-
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].filenamePattern is not a valid regex`);
|
|
17231
|
-
}
|
|
17232
|
-
}
|
|
17233
|
-
if (ruleObj.skipRows !== undefined) {
|
|
17234
|
-
if (typeof ruleObj.skipRows !== "number" || ruleObj.skipRows < 0) {
|
|
17235
|
-
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].skipRows must be a non-negative number`);
|
|
17236
|
-
}
|
|
17237
|
-
}
|
|
17238
|
-
if (ruleObj.delimiter !== undefined) {
|
|
17239
|
-
if (typeof ruleObj.delimiter !== "string" || ruleObj.delimiter.length !== 1) {
|
|
17240
|
-
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].delimiter must be a single character`);
|
|
17241
|
-
}
|
|
17242
|
-
}
|
|
17243
|
-
if (ruleObj.renamePattern !== undefined) {
|
|
17244
|
-
if (typeof ruleObj.renamePattern !== "string") {
|
|
17245
|
-
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].renamePattern must be a string`);
|
|
17246
|
-
}
|
|
17247
|
-
}
|
|
17248
|
-
if (ruleObj.metadata !== undefined) {
|
|
17249
|
-
if (!Array.isArray(ruleObj.metadata)) {
|
|
17250
|
-
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].metadata must be an array`);
|
|
17251
|
-
}
|
|
17252
|
-
for (let i2 = 0;i2 < ruleObj.metadata.length; i2++) {
|
|
17253
|
-
const meta = ruleObj.metadata[i2];
|
|
17254
|
-
if (typeof meta.field !== "string" || meta.field === "") {
|
|
17255
|
-
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].metadata[${i2}].field is required`);
|
|
17256
|
-
}
|
|
17257
|
-
if (typeof meta.row !== "number" || meta.row < 0) {
|
|
17258
|
-
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].metadata[${i2}].row must be a non-negative number`);
|
|
17259
|
-
}
|
|
17260
|
-
if (typeof meta.column !== "number" || meta.column < 0) {
|
|
17261
|
-
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].metadata[${i2}].column must be a non-negative number`);
|
|
17262
|
-
}
|
|
17263
|
-
if (meta.normalize !== undefined && meta.normalize !== "spaces-to-dashes") {
|
|
17264
|
-
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].metadata[${i2}].normalize must be 'spaces-to-dashes'`);
|
|
17265
|
-
}
|
|
17266
|
-
}
|
|
17267
|
-
}
|
|
17268
|
-
return {
|
|
17269
|
-
filenamePattern: ruleObj.filenamePattern,
|
|
17270
|
-
header: ruleObj.header,
|
|
17271
|
-
currencyField: ruleObj.currencyField,
|
|
17272
|
-
skipRows: ruleObj.skipRows,
|
|
17273
|
-
delimiter: ruleObj.delimiter,
|
|
17274
|
-
renamePattern: ruleObj.renamePattern,
|
|
17275
|
-
metadata: ruleObj.metadata
|
|
17276
|
-
};
|
|
17277
|
-
}
|
|
17278
|
-
function validateProviderConfig(name, config2) {
|
|
17279
|
-
if (typeof config2 !== "object" || config2 === null) {
|
|
17280
|
-
throw new Error(`Invalid config for provider '${name}': expected an object`);
|
|
17281
|
-
}
|
|
17282
|
-
const configObj = config2;
|
|
17283
|
-
if (!Array.isArray(configObj.detect) || configObj.detect.length === 0) {
|
|
17284
|
-
throw new Error(`Invalid config for provider '${name}': 'detect' must be a non-empty array`);
|
|
17285
|
-
}
|
|
17286
|
-
const detect = [];
|
|
17287
|
-
for (let i2 = 0;i2 < configObj.detect.length; i2++) {
|
|
17288
|
-
detect.push(validateDetectionRule(name, i2, configObj.detect[i2]));
|
|
17289
|
-
}
|
|
17290
|
-
if (typeof configObj.currencies !== "object" || configObj.currencies === null) {
|
|
17291
|
-
throw new Error(`Invalid config for provider '${name}': 'currencies' must be an object`);
|
|
17292
|
-
}
|
|
17293
|
-
const currenciesObj = configObj.currencies;
|
|
17294
|
-
const currencies = {};
|
|
17295
|
-
for (const [key, value] of Object.entries(currenciesObj)) {
|
|
17296
|
-
if (typeof value !== "string") {
|
|
17297
|
-
throw new Error(`Invalid config for provider '${name}': currencies.${key} must be a string`);
|
|
17298
|
-
}
|
|
17299
|
-
currencies[key] = value;
|
|
17300
|
-
}
|
|
17301
|
-
if (Object.keys(currencies).length === 0) {
|
|
17302
|
-
throw new Error(`Invalid config for provider '${name}': 'currencies' must contain at least one mapping`);
|
|
17303
|
-
}
|
|
17304
|
-
return { detect, currencies };
|
|
17305
|
-
}
|
|
17306
|
-
function loadImportConfig(directory) {
|
|
17307
|
-
const configPath = path3.join(directory, CONFIG_FILE2);
|
|
17308
|
-
if (!fs3.existsSync(configPath)) {
|
|
17309
|
-
throw new Error(`Configuration file not found: ${CONFIG_FILE2}. Please create this file to configure statement imports.`);
|
|
17310
|
-
}
|
|
17311
|
-
let parsed;
|
|
17312
|
-
try {
|
|
17313
|
-
const content = fs3.readFileSync(configPath, "utf-8");
|
|
17314
|
-
parsed = jsYaml.load(content);
|
|
17315
|
-
} catch (err) {
|
|
17316
|
-
if (err instanceof jsYaml.YAMLException) {
|
|
17317
|
-
throw new Error(`Failed to parse ${CONFIG_FILE2}: ${err.message}`);
|
|
17318
|
-
}
|
|
17319
|
-
throw err;
|
|
17320
|
-
}
|
|
17321
|
-
if (typeof parsed !== "object" || parsed === null) {
|
|
17322
|
-
throw new Error(`Invalid config: ${CONFIG_FILE2} must contain a YAML object`);
|
|
17323
|
-
}
|
|
17324
|
-
const parsedObj = parsed;
|
|
17325
|
-
if (!parsedObj.paths) {
|
|
17326
|
-
throw new Error("Invalid config: 'paths' section is required");
|
|
17327
|
-
}
|
|
17328
|
-
const paths = validatePaths(parsedObj.paths);
|
|
17329
|
-
if (!parsedObj.providers || typeof parsedObj.providers !== "object") {
|
|
17330
|
-
throw new Error("Invalid config: 'providers' section is required");
|
|
17331
|
-
}
|
|
17332
|
-
const providersObj = parsedObj.providers;
|
|
17333
|
-
if (Object.keys(providersObj).length === 0) {
|
|
17334
|
-
throw new Error("Invalid config: 'providers' section must contain at least one provider");
|
|
17335
|
-
}
|
|
17336
|
-
const providers = {};
|
|
17337
|
-
for (const [name, config2] of Object.entries(providersObj)) {
|
|
17338
|
-
providers[name] = validateProviderConfig(name, config2);
|
|
17339
|
-
}
|
|
17340
|
-
return { paths, providers };
|
|
17341
|
-
}
|
|
17342
|
-
|
|
17343
17191
|
// src/utils/providerDetector.ts
|
|
17344
17192
|
var import_papaparse = __toESM(require_papaparse(), 1);
|
|
17345
|
-
function extractMetadata(content, skipRows, delimiter, metadataConfig) {
|
|
17346
|
-
if (!metadataConfig || metadataConfig.length === 0 || skipRows === 0) {
|
|
17347
|
-
return {};
|
|
17348
|
-
}
|
|
17349
|
-
const lines = content.split(`
|
|
17350
|
-
`).slice(0, skipRows);
|
|
17351
|
-
const metadata = {};
|
|
17352
|
-
for (const config2 of metadataConfig) {
|
|
17353
|
-
if (config2.row >= lines.length)
|
|
17354
|
-
continue;
|
|
17355
|
-
const columns = lines[config2.row].split(delimiter);
|
|
17356
|
-
if (config2.column >= columns.length)
|
|
17357
|
-
continue;
|
|
17358
|
-
let value = columns[config2.column].trim();
|
|
17359
|
-
if (config2.normalize === "spaces-to-dashes") {
|
|
17360
|
-
value = value.replace(/\s+/g, "-");
|
|
17361
|
-
}
|
|
17362
|
-
metadata[config2.field] = value;
|
|
17363
|
-
}
|
|
17364
|
-
return metadata;
|
|
17365
|
-
}
|
|
17366
|
-
function generateOutputFilename(renamePattern, metadata) {
|
|
17367
|
-
if (!renamePattern) {
|
|
17368
|
-
return;
|
|
17369
|
-
}
|
|
17370
|
-
let filename = renamePattern;
|
|
17371
|
-
for (const [key, value] of Object.entries(metadata)) {
|
|
17372
|
-
filename = filename.replace(`{${key}}`, value);
|
|
17373
|
-
}
|
|
17374
|
-
return filename;
|
|
17375
|
-
}
|
|
17376
|
-
function parseCSVPreview(content, skipRows = 0, delimiter = ",") {
|
|
17377
|
-
let csvContent = content;
|
|
17378
|
-
if (skipRows > 0) {
|
|
17379
|
-
const lines = content.split(`
|
|
17380
|
-
`);
|
|
17381
|
-
csvContent = lines.slice(skipRows).join(`
|
|
17382
|
-
`);
|
|
17383
|
-
}
|
|
17384
|
-
const result = import_papaparse.default.parse(csvContent, {
|
|
17385
|
-
header: true,
|
|
17386
|
-
preview: 1,
|
|
17387
|
-
skipEmptyLines: true,
|
|
17388
|
-
delimiter
|
|
17389
|
-
});
|
|
17390
|
-
return {
|
|
17391
|
-
fields: result.meta.fields,
|
|
17392
|
-
firstRow: result.data[0]
|
|
17393
|
-
};
|
|
17394
|
-
}
|
|
17395
|
-
function normalizeHeader(fields) {
|
|
17396
|
-
return fields.map((f) => f.trim()).join(",");
|
|
17397
|
-
}
|
|
17398
|
-
function detectProvider(filename, content, config2) {
|
|
17399
|
-
for (const [providerName, providerConfig] of Object.entries(config2.providers)) {
|
|
17400
|
-
for (const rule of providerConfig.detect) {
|
|
17401
|
-
if (rule.filenamePattern !== undefined) {
|
|
17402
|
-
const filenameRegex = new RegExp(rule.filenamePattern);
|
|
17403
|
-
if (!filenameRegex.test(filename)) {
|
|
17404
|
-
continue;
|
|
17405
|
-
}
|
|
17406
|
-
}
|
|
17407
|
-
const skipRows = rule.skipRows ?? 0;
|
|
17408
|
-
const delimiter = rule.delimiter ?? ",";
|
|
17409
|
-
const { fields, firstRow } = parseCSVPreview(content, skipRows, delimiter);
|
|
17410
|
-
if (!fields || fields.length === 0) {
|
|
17411
|
-
continue;
|
|
17412
|
-
}
|
|
17413
|
-
const actualHeader = normalizeHeader(fields);
|
|
17414
|
-
if (actualHeader !== rule.header) {
|
|
17415
|
-
continue;
|
|
17416
|
-
}
|
|
17417
|
-
if (!firstRow) {
|
|
17418
|
-
continue;
|
|
17419
|
-
}
|
|
17420
|
-
const rawCurrency = firstRow[rule.currencyField];
|
|
17421
|
-
if (!rawCurrency) {
|
|
17422
|
-
continue;
|
|
17423
|
-
}
|
|
17424
|
-
const metadata = extractMetadata(content, skipRows, delimiter, rule.metadata);
|
|
17425
|
-
const outputFilename = generateOutputFilename(rule.renamePattern, metadata);
|
|
17426
|
-
const normalizedCurrency = providerConfig.currencies[rawCurrency];
|
|
17427
|
-
if (!normalizedCurrency) {
|
|
17428
|
-
return {
|
|
17429
|
-
provider: providerName,
|
|
17430
|
-
currency: rawCurrency.toLowerCase(),
|
|
17431
|
-
rule,
|
|
17432
|
-
outputFilename,
|
|
17433
|
-
metadata: Object.keys(metadata).length > 0 ? metadata : undefined
|
|
17434
|
-
};
|
|
17435
|
-
}
|
|
17436
|
-
return {
|
|
17437
|
-
provider: providerName,
|
|
17438
|
-
currency: normalizedCurrency,
|
|
17439
|
-
rule,
|
|
17440
|
-
outputFilename,
|
|
17441
|
-
metadata: Object.keys(metadata).length > 0 ? metadata : undefined
|
|
17442
|
-
};
|
|
17443
|
-
}
|
|
17444
|
-
}
|
|
17445
|
-
return null;
|
|
17446
|
-
}
|
|
17447
|
-
|
|
17448
|
-
// src/tools/classify-statements.ts
|
|
17449
|
-
function findCSVFiles(importsDir) {
|
|
17450
|
-
if (!fs4.existsSync(importsDir)) {
|
|
17451
|
-
return [];
|
|
17452
|
-
}
|
|
17453
|
-
return fs4.readdirSync(importsDir).filter((file2) => file2.toLowerCase().endsWith(".csv")).filter((file2) => {
|
|
17454
|
-
const fullPath = path4.join(importsDir, file2);
|
|
17455
|
-
return fs4.statSync(fullPath).isFile();
|
|
17456
|
-
});
|
|
17457
|
-
}
|
|
17458
|
-
function ensureDirectory(dirPath) {
|
|
17459
|
-
if (!fs4.existsSync(dirPath)) {
|
|
17460
|
-
fs4.mkdirSync(dirPath, { recursive: true });
|
|
17461
|
-
}
|
|
17462
|
-
}
|
|
17463
|
-
async function classifyStatementsCore(directory, agent, configLoader = loadImportConfig) {
|
|
17464
|
-
if (agent !== "accountant") {
|
|
17465
|
-
return JSON.stringify({
|
|
17466
|
-
success: false,
|
|
17467
|
-
error: "This tool is restricted to the accountant agent only.",
|
|
17468
|
-
hint: "Use: Task(subagent_type='accountant', prompt='classify statements')",
|
|
17469
|
-
caller: agent || "main assistant",
|
|
17470
|
-
classified: [],
|
|
17471
|
-
unrecognized: []
|
|
17472
|
-
});
|
|
17473
|
-
}
|
|
17474
|
-
let config2;
|
|
17475
|
-
try {
|
|
17476
|
-
config2 = configLoader(directory);
|
|
17477
|
-
} catch (err) {
|
|
17478
|
-
return JSON.stringify({
|
|
17479
|
-
success: false,
|
|
17480
|
-
error: err instanceof Error ? err.message : String(err),
|
|
17481
|
-
classified: [],
|
|
17482
|
-
unrecognized: []
|
|
17483
|
-
});
|
|
17484
|
-
}
|
|
17485
|
-
const importsDir = path4.join(directory, config2.paths.import);
|
|
17486
|
-
const pendingDir = path4.join(directory, config2.paths.pending);
|
|
17487
|
-
const unrecognizedDir = path4.join(directory, config2.paths.unrecognized);
|
|
17488
|
-
const csvFiles = findCSVFiles(importsDir);
|
|
17489
|
-
if (csvFiles.length === 0) {
|
|
17490
|
-
return JSON.stringify({
|
|
17491
|
-
success: true,
|
|
17492
|
-
classified: [],
|
|
17493
|
-
unrecognized: [],
|
|
17494
|
-
message: `No CSV files found in ${config2.paths.import}`
|
|
17495
|
-
});
|
|
17496
|
-
}
|
|
17497
|
-
const plannedMoves = [];
|
|
17498
|
-
const collisions = [];
|
|
17499
|
-
for (const filename of csvFiles) {
|
|
17500
|
-
const sourcePath = path4.join(importsDir, filename);
|
|
17501
|
-
const content = fs4.readFileSync(sourcePath, "utf-8");
|
|
17502
|
-
const detection = detectProvider(filename, content, config2);
|
|
17503
|
-
let targetPath;
|
|
17504
|
-
let targetFilename;
|
|
17505
|
-
if (detection) {
|
|
17506
|
-
targetFilename = detection.outputFilename || filename;
|
|
17507
|
-
const targetDir = path4.join(pendingDir, detection.provider, detection.currency);
|
|
17508
|
-
targetPath = path4.join(targetDir, targetFilename);
|
|
17509
|
-
} else {
|
|
17510
|
-
targetFilename = filename;
|
|
17511
|
-
targetPath = path4.join(unrecognizedDir, filename);
|
|
17512
|
-
}
|
|
17513
|
-
if (fs4.existsSync(targetPath)) {
|
|
17514
|
-
collisions.push({
|
|
17515
|
-
filename,
|
|
17516
|
-
existingPath: targetPath
|
|
17517
|
-
});
|
|
17518
|
-
}
|
|
17519
|
-
plannedMoves.push({
|
|
17520
|
-
filename,
|
|
17521
|
-
sourcePath,
|
|
17522
|
-
targetPath,
|
|
17523
|
-
targetFilename,
|
|
17524
|
-
detection
|
|
17525
|
-
});
|
|
17526
|
-
}
|
|
17527
|
-
if (collisions.length > 0) {
|
|
17528
|
-
return JSON.stringify({
|
|
17529
|
-
success: false,
|
|
17530
|
-
error: `Cannot classify: ${collisions.length} file(s) would overwrite existing pending files.`,
|
|
17531
|
-
collisions,
|
|
17532
|
-
classified: [],
|
|
17533
|
-
unrecognized: []
|
|
17534
|
-
});
|
|
17535
|
-
}
|
|
17536
|
-
const classified = [];
|
|
17537
|
-
const unrecognized = [];
|
|
17538
|
-
for (const move of plannedMoves) {
|
|
17539
|
-
if (move.detection) {
|
|
17540
|
-
const targetDir = path4.dirname(move.targetPath);
|
|
17541
|
-
ensureDirectory(targetDir);
|
|
17542
|
-
fs4.renameSync(move.sourcePath, move.targetPath);
|
|
17543
|
-
classified.push({
|
|
17544
|
-
filename: move.targetFilename,
|
|
17545
|
-
originalFilename: move.detection.outputFilename ? move.filename : undefined,
|
|
17546
|
-
provider: move.detection.provider,
|
|
17547
|
-
currency: move.detection.currency,
|
|
17548
|
-
targetPath: path4.join(config2.paths.pending, move.detection.provider, move.detection.currency, move.targetFilename)
|
|
17549
|
-
});
|
|
17550
|
-
} else {
|
|
17551
|
-
ensureDirectory(unrecognizedDir);
|
|
17552
|
-
fs4.renameSync(move.sourcePath, move.targetPath);
|
|
17553
|
-
unrecognized.push({
|
|
17554
|
-
filename: move.filename,
|
|
17555
|
-
targetPath: path4.join(config2.paths.unrecognized, move.filename)
|
|
17556
|
-
});
|
|
17557
|
-
}
|
|
17558
|
-
}
|
|
17559
|
-
return JSON.stringify({
|
|
17560
|
-
success: true,
|
|
17561
|
-
classified,
|
|
17562
|
-
unrecognized,
|
|
17563
|
-
summary: {
|
|
17564
|
-
total: csvFiles.length,
|
|
17565
|
-
classified: classified.length,
|
|
17566
|
-
unrecognized: unrecognized.length
|
|
17567
|
-
}
|
|
17568
|
-
});
|
|
17569
|
-
}
|
|
17570
|
-
var classify_statements_default = tool({
|
|
17571
|
-
description: "ACCOUNTANT AGENT ONLY: Classifies bank statement CSV files from the imports directory by detecting their provider and currency, then moves them to the appropriate pending import directories.",
|
|
17572
|
-
args: {},
|
|
17573
|
-
async execute(_params, context) {
|
|
17574
|
-
const { directory, agent } = context;
|
|
17575
|
-
return classifyStatementsCore(directory, agent);
|
|
17576
|
-
}
|
|
17577
|
-
});
|
|
17578
|
-
// src/tools/import-statements.ts
|
|
17579
|
-
import * as fs7 from "fs";
|
|
17580
|
-
import * as path6 from "path";
|
|
17581
|
-
|
|
17582
|
-
// src/utils/rulesMatcher.ts
|
|
17583
|
-
import * as fs5 from "fs";
|
|
17584
|
-
import * as path5 from "path";
|
|
17585
|
-
function parseSourceDirective(content) {
|
|
17586
|
-
const match = content.match(/^source\s+([^\n#]+)/m);
|
|
17587
|
-
if (!match) {
|
|
17588
|
-
return null;
|
|
17589
|
-
}
|
|
17590
|
-
return match[1].trim();
|
|
17591
|
-
}
|
|
17592
|
-
function resolveSourcePath(sourcePath, rulesFilePath) {
|
|
17593
|
-
if (path5.isAbsolute(sourcePath)) {
|
|
17594
|
-
return sourcePath;
|
|
17595
|
-
}
|
|
17596
|
-
const rulesDir = path5.dirname(rulesFilePath);
|
|
17597
|
-
return path5.resolve(rulesDir, sourcePath);
|
|
17598
|
-
}
|
|
17599
|
-
function loadRulesMapping(rulesDir) {
|
|
17600
|
-
const mapping = {};
|
|
17601
|
-
if (!fs5.existsSync(rulesDir)) {
|
|
17602
|
-
return mapping;
|
|
17603
|
-
}
|
|
17604
|
-
const files = fs5.readdirSync(rulesDir);
|
|
17605
|
-
for (const file2 of files) {
|
|
17606
|
-
if (!file2.endsWith(".rules")) {
|
|
17607
|
-
continue;
|
|
17608
|
-
}
|
|
17609
|
-
const rulesFilePath = path5.join(rulesDir, file2);
|
|
17610
|
-
const stat = fs5.statSync(rulesFilePath);
|
|
17611
|
-
if (!stat.isFile()) {
|
|
17612
|
-
continue;
|
|
17613
|
-
}
|
|
17614
|
-
const content = fs5.readFileSync(rulesFilePath, "utf-8");
|
|
17615
|
-
const sourcePath = parseSourceDirective(content);
|
|
17616
|
-
if (!sourcePath) {
|
|
17617
|
-
continue;
|
|
17618
|
-
}
|
|
17619
|
-
const absoluteCsvPath = resolveSourcePath(sourcePath, rulesFilePath);
|
|
17620
|
-
mapping[absoluteCsvPath] = rulesFilePath;
|
|
17621
|
-
}
|
|
17622
|
-
return mapping;
|
|
17623
|
-
}
|
|
17624
|
-
function findRulesForCsv(csvPath, mapping) {
|
|
17625
|
-
if (mapping[csvPath]) {
|
|
17626
|
-
return mapping[csvPath];
|
|
17627
|
-
}
|
|
17628
|
-
const normalizedCsvPath = path5.normalize(csvPath);
|
|
17629
|
-
for (const [mappedCsv, rulesFile] of Object.entries(mapping)) {
|
|
17630
|
-
if (path5.normalize(mappedCsv) === normalizedCsvPath) {
|
|
17631
|
-
return rulesFile;
|
|
17632
|
-
}
|
|
17633
|
-
}
|
|
17634
|
-
return null;
|
|
17635
|
-
}
|
|
17636
|
-
|
|
17637
|
-
// src/utils/hledgerExecutor.ts
|
|
17638
|
-
var {$: $2 } = globalThis.Bun;
|
|
17639
|
-
async function defaultHledgerExecutor(cmdArgs) {
|
|
17640
|
-
try {
|
|
17641
|
-
const result = await $2`hledger ${cmdArgs}`.quiet().nothrow();
|
|
17642
|
-
return {
|
|
17643
|
-
stdout: result.stdout.toString(),
|
|
17644
|
-
stderr: result.stderr.toString(),
|
|
17645
|
-
exitCode: result.exitCode
|
|
17646
|
-
};
|
|
17647
|
-
} catch (error45) {
|
|
17648
|
-
return {
|
|
17649
|
-
stdout: "",
|
|
17650
|
-
stderr: error45 instanceof Error ? error45.message : String(error45),
|
|
17651
|
-
exitCode: 1
|
|
17652
|
-
};
|
|
17653
|
-
}
|
|
17654
|
-
}
|
|
17655
|
-
function parseUnknownPostings(hledgerOutput) {
|
|
17656
|
-
const unknownPostings = [];
|
|
17657
|
-
const lines = hledgerOutput.split(`
|
|
17658
|
-
`);
|
|
17659
|
-
let currentDate = "";
|
|
17660
|
-
let currentDescription = "";
|
|
17661
|
-
for (const line of lines) {
|
|
17662
|
-
const headerMatch = line.match(/^(\d{4}-\d{2}-\d{2})\s+(.+)$/);
|
|
17663
|
-
if (headerMatch) {
|
|
17664
|
-
currentDate = headerMatch[1];
|
|
17665
|
-
currentDescription = headerMatch[2].trim();
|
|
17666
|
-
continue;
|
|
17667
|
-
}
|
|
17668
|
-
const postingMatch = line.match(/^\s+(income:unknown|expenses:unknown)\s+([^\s]+(?:\s+[^\s=]+)?)\s*(?:=\s*(.+))?$/);
|
|
17669
|
-
if (postingMatch && currentDate) {
|
|
17670
|
-
unknownPostings.push({
|
|
17671
|
-
date: currentDate,
|
|
17672
|
-
description: currentDescription,
|
|
17673
|
-
amount: postingMatch[2].trim(),
|
|
17674
|
-
account: postingMatch[1],
|
|
17675
|
-
balance: postingMatch[3]?.trim()
|
|
17676
|
-
});
|
|
17677
|
-
}
|
|
17678
|
-
}
|
|
17679
|
-
return unknownPostings;
|
|
17680
|
-
}
|
|
17681
|
-
function countTransactions(hledgerOutput) {
|
|
17682
|
-
const lines = hledgerOutput.split(`
|
|
17683
|
-
`);
|
|
17684
|
-
let count = 0;
|
|
17685
|
-
for (const line of lines) {
|
|
17686
|
-
if (/^\d{4}-\d{2}-\d{2}\s+/.test(line)) {
|
|
17687
|
-
count++;
|
|
17688
|
-
}
|
|
17689
|
-
}
|
|
17690
|
-
return count;
|
|
17691
|
-
}
|
|
17692
|
-
function extractTransactionYears(hledgerOutput) {
|
|
17693
|
-
const years = new Set;
|
|
17694
|
-
const lines = hledgerOutput.split(`
|
|
17695
|
-
`);
|
|
17696
|
-
for (const line of lines) {
|
|
17697
|
-
const match = line.match(/^(\d{4})-\d{2}-\d{2}\s+/);
|
|
17698
|
-
if (match) {
|
|
17699
|
-
years.add(parseInt(match[1], 10));
|
|
17700
|
-
}
|
|
17701
|
-
}
|
|
17702
|
-
return years;
|
|
17703
|
-
}
|
|
17704
|
-
async function validateLedger(mainJournalPath, executor = defaultHledgerExecutor) {
|
|
17705
|
-
const errors3 = [];
|
|
17706
|
-
const checkResult = await executor(["check", "--strict", "-f", mainJournalPath]);
|
|
17707
|
-
if (checkResult.exitCode !== 0) {
|
|
17708
|
-
const errorMsg = checkResult.stderr.trim() || checkResult.stdout.trim();
|
|
17709
|
-
errors3.push(`hledger check --strict failed: ${errorMsg}`);
|
|
17710
|
-
}
|
|
17711
|
-
const balResult = await executor(["bal", "-f", mainJournalPath]);
|
|
17712
|
-
if (balResult.exitCode !== 0) {
|
|
17713
|
-
const errorMsg = balResult.stderr.trim() || balResult.stdout.trim();
|
|
17714
|
-
errors3.push(`hledger bal failed: ${errorMsg}`);
|
|
17715
|
-
}
|
|
17716
|
-
return { valid: errors3.length === 0, errors: errors3 };
|
|
17717
|
-
}
|
|
17718
|
-
|
|
17719
|
-
// src/utils/rulesParser.ts
|
|
17720
|
-
function parseSkipRows(rulesContent) {
|
|
17721
|
-
const match = rulesContent.match(/^skip\s+(\d+)/m);
|
|
17722
|
-
return match ? parseInt(match[1], 10) : 0;
|
|
17723
|
-
}
|
|
17724
|
-
function parseSeparator(rulesContent) {
|
|
17725
|
-
const match = rulesContent.match(/^separator\s+(.)/m);
|
|
17726
|
-
return match ? match[1] : ",";
|
|
17727
|
-
}
|
|
17728
|
-
function parseFieldNames(rulesContent) {
|
|
17729
|
-
const match = rulesContent.match(/^fields\s+(.+)$/m);
|
|
17730
|
-
if (!match) {
|
|
17731
|
-
return [];
|
|
17732
|
-
}
|
|
17733
|
-
return match[1].split(",").map((field) => field.trim());
|
|
17734
|
-
}
|
|
17735
|
-
function parseDateFormat(rulesContent) {
|
|
17736
|
-
const match = rulesContent.match(/^date-format\s+(.+)$/m);
|
|
17737
|
-
return match ? match[1].trim() : "%Y-%m-%d";
|
|
17738
|
-
}
|
|
17739
|
-
function parseDateField(rulesContent, fieldNames) {
|
|
17740
|
-
const match = rulesContent.match(/^date\s+%(\w+|\d+)/m);
|
|
17741
|
-
if (!match) {
|
|
17742
|
-
return fieldNames[0] || "date";
|
|
17743
|
-
}
|
|
17744
|
-
const value = match[1];
|
|
17745
|
-
if (/^\d+$/.test(value)) {
|
|
17746
|
-
const index = parseInt(value, 10) - 1;
|
|
17747
|
-
return fieldNames[index] || value;
|
|
17748
|
-
}
|
|
17749
|
-
return value;
|
|
17750
|
-
}
|
|
17751
|
-
function parseAmountFields(rulesContent, fieldNames) {
|
|
17752
|
-
const result = {};
|
|
17753
|
-
const simpleMatch = rulesContent.match(/^amount\s+(-?)%(\w+|\d+)/m);
|
|
17754
|
-
if (simpleMatch) {
|
|
17755
|
-
const fieldRef = simpleMatch[2];
|
|
17756
|
-
if (/^\d+$/.test(fieldRef)) {
|
|
17757
|
-
const index = parseInt(fieldRef, 10) - 1;
|
|
17758
|
-
result.single = fieldNames[index] || fieldRef;
|
|
17759
|
-
} else {
|
|
17760
|
-
result.single = fieldRef;
|
|
17761
|
-
}
|
|
17762
|
-
}
|
|
17763
|
-
const debitMatch = rulesContent.match(/if\s+%(\w+)\s+\.\s*\n\s*amount\s+-?%\1/m);
|
|
17764
|
-
if (debitMatch) {
|
|
17765
|
-
result.debit = debitMatch[1];
|
|
17766
|
-
}
|
|
17767
|
-
const creditMatch = rulesContent.match(/if\s+%(\w+)\s+\.\s*\n\s*amount\s+%\1(?!\w)/m);
|
|
17768
|
-
if (creditMatch && creditMatch[1] !== result.debit) {
|
|
17769
|
-
result.credit = creditMatch[1];
|
|
17770
|
-
}
|
|
17771
|
-
if (result.debit || result.credit) {
|
|
17772
|
-
delete result.single;
|
|
17773
|
-
}
|
|
17774
|
-
if (!result.single && !result.debit && !result.credit) {
|
|
17775
|
-
result.single = "amount";
|
|
17776
|
-
}
|
|
17777
|
-
return result;
|
|
17778
|
-
}
|
|
17779
|
-
function parseRulesFile(rulesContent) {
|
|
17780
|
-
const fieldNames = parseFieldNames(rulesContent);
|
|
17781
|
-
return {
|
|
17782
|
-
skipRows: parseSkipRows(rulesContent),
|
|
17783
|
-
separator: parseSeparator(rulesContent),
|
|
17784
|
-
fieldNames,
|
|
17785
|
-
dateFormat: parseDateFormat(rulesContent),
|
|
17786
|
-
dateField: parseDateField(rulesContent, fieldNames),
|
|
17787
|
-
amountFields: parseAmountFields(rulesContent, fieldNames)
|
|
17788
|
-
};
|
|
17789
|
-
}
|
|
17790
|
-
|
|
17791
17193
|
// src/utils/csvParser.ts
|
|
17792
17194
|
var import_convert_csv_to_json = __toESM(require_convert_csv_to_json(), 1);
|
|
17793
|
-
import * as fs6 from "fs";
|
|
17794
|
-
function parseCsvFile(csvPath, config2) {
|
|
17795
|
-
const csvContent = fs6.readFileSync(csvPath, "utf-8");
|
|
17796
|
-
const lines = csvContent.split(`
|
|
17797
|
-
`);
|
|
17798
|
-
const headerIndex = config2.skipRows;
|
|
17799
|
-
if (headerIndex >= lines.length) {
|
|
17800
|
-
return [];
|
|
17801
|
-
}
|
|
17802
|
-
const headerLine = lines[headerIndex];
|
|
17803
|
-
const dataLines = lines.slice(headerIndex + 1).filter((line) => line.trim() !== "");
|
|
17804
|
-
const csvWithHeader = [headerLine, ...dataLines].join(`
|
|
17805
|
-
`);
|
|
17806
|
-
const rawRows = import_convert_csv_to_json.default.indexHeader(0).fieldDelimiter(config2.separator).supportQuotedField(true).csvStringToJson(csvWithHeader);
|
|
17807
|
-
const fieldNames = config2.fieldNames.length > 0 ? config2.fieldNames : Object.keys(rawRows[0] || {});
|
|
17808
|
-
const mappedRows = [];
|
|
17809
|
-
for (const parsedRow of rawRows) {
|
|
17810
|
-
const row = {};
|
|
17811
|
-
const values = Object.values(parsedRow);
|
|
17812
|
-
for (let i2 = 0;i2 < fieldNames.length && i2 < values.length; i2++) {
|
|
17813
|
-
row[fieldNames[i2]] = values[i2];
|
|
17814
|
-
}
|
|
17815
|
-
mappedRows.push(row);
|
|
17816
|
-
}
|
|
17817
|
-
return mappedRows;
|
|
17818
|
-
}
|
|
17819
|
-
function parseAmountValue(amountStr) {
|
|
17820
|
-
const cleaned = amountStr.replace(/[A-Z]{3}\s*/g, "").trim();
|
|
17821
|
-
return parseFloat(cleaned) || 0;
|
|
17822
|
-
}
|
|
17823
|
-
function getRowAmount(row, amountFields) {
|
|
17824
|
-
if (amountFields.single) {
|
|
17825
|
-
return parseAmountValue(row[amountFields.single] || "0");
|
|
17826
|
-
}
|
|
17827
|
-
const debitValue = amountFields.debit ? parseAmountValue(row[amountFields.debit] || "0") : 0;
|
|
17828
|
-
const creditValue = amountFields.credit ? parseAmountValue(row[amountFields.credit] || "0") : 0;
|
|
17829
|
-
if (debitValue !== 0) {
|
|
17830
|
-
return -Math.abs(debitValue);
|
|
17831
|
-
}
|
|
17832
|
-
if (creditValue !== 0) {
|
|
17833
|
-
return Math.abs(creditValue);
|
|
17834
|
-
}
|
|
17835
|
-
return 0;
|
|
17836
|
-
}
|
|
17837
|
-
function parseDateToIso(dateStr, dateFormat) {
|
|
17838
|
-
if (!dateStr)
|
|
17839
|
-
return "";
|
|
17840
|
-
if (dateFormat === "%Y-%m-%d" || dateFormat === "%F") {
|
|
17841
|
-
return dateStr.trim();
|
|
17842
|
-
}
|
|
17843
|
-
if (dateFormat === "%d.%m.%Y") {
|
|
17844
|
-
const parts = dateStr.split(".");
|
|
17845
|
-
if (parts.length === 3) {
|
|
17846
|
-
return `${parts[2]}-${parts[1].padStart(2, "0")}-${parts[0].padStart(2, "0")}`;
|
|
17847
|
-
}
|
|
17848
|
-
}
|
|
17849
|
-
if (dateFormat === "%m/%d/%Y") {
|
|
17850
|
-
const parts = dateStr.split("/");
|
|
17851
|
-
if (parts.length === 3) {
|
|
17852
|
-
return `${parts[2]}-${parts[0].padStart(2, "0")}-${parts[1].padStart(2, "0")}`;
|
|
17853
|
-
}
|
|
17854
|
-
}
|
|
17855
|
-
if (dateFormat === "%d/%m/%Y") {
|
|
17856
|
-
const parts = dateStr.split("/");
|
|
17857
|
-
if (parts.length === 3) {
|
|
17858
|
-
return `${parts[2]}-${parts[1].padStart(2, "0")}-${parts[0].padStart(2, "0")}`;
|
|
17859
|
-
}
|
|
17860
|
-
}
|
|
17861
|
-
return dateStr.trim();
|
|
17862
|
-
}
|
|
17863
|
-
function looksLikeTransactionId(fieldName, value) {
|
|
17864
|
-
if (!value || value.trim() === "")
|
|
17865
|
-
return false;
|
|
17866
|
-
const idFieldPatterns = [
|
|
17867
|
-
/transaction/i,
|
|
17868
|
-
/trans_?no/i,
|
|
17869
|
-
/trans_?id/i,
|
|
17870
|
-
/reference/i,
|
|
17871
|
-
/ref_?no/i,
|
|
17872
|
-
/ref_?id/i,
|
|
17873
|
-
/booking_?id/i,
|
|
17874
|
-
/payment_?id/i,
|
|
17875
|
-
/order_?id/i
|
|
17876
|
-
];
|
|
17877
|
-
const nameMatches = idFieldPatterns.some((pattern) => pattern.test(fieldName));
|
|
17878
|
-
if (!nameMatches)
|
|
17879
|
-
return false;
|
|
17880
|
-
const trimmedValue = value.trim();
|
|
17881
|
-
const looksLikeId = /^[A-Za-z0-9_-]+$/.test(trimmedValue) && trimmedValue.length >= 3;
|
|
17882
|
-
return looksLikeId;
|
|
17883
|
-
}
|
|
17884
|
-
function findTransactionId(row) {
|
|
17885
|
-
for (const [field, value] of Object.entries(row)) {
|
|
17886
|
-
if (looksLikeTransactionId(field, value)) {
|
|
17887
|
-
return { field, value: value.trim() };
|
|
17888
|
-
}
|
|
17889
|
-
}
|
|
17890
|
-
return null;
|
|
17891
|
-
}
|
|
17892
|
-
function findMatchingCsvRow(posting, csvRows, config2) {
|
|
17893
|
-
const postingAmount = parseAmountValue(posting.amount);
|
|
17894
|
-
let candidates = csvRows.filter((row) => {
|
|
17895
|
-
const rowDate = parseDateToIso(row[config2.dateField] || "", config2.dateFormat);
|
|
17896
|
-
const rowAmount = getRowAmount(row, config2.amountFields);
|
|
17897
|
-
if (rowDate !== posting.date)
|
|
17898
|
-
return false;
|
|
17899
|
-
if (Math.abs(rowAmount - postingAmount) > 0.001)
|
|
17900
|
-
return false;
|
|
17901
|
-
return true;
|
|
17902
|
-
});
|
|
17903
|
-
if (candidates.length === 1) {
|
|
17904
|
-
return candidates[0];
|
|
17905
|
-
}
|
|
17906
|
-
if (candidates.length === 0) {
|
|
17907
|
-
throw new Error(`Bug: Could not find CSV row for posting: ${posting.date} ${posting.description} ${posting.amount}. ` + `This indicates a mismatch between hledger output and CSV parsing.`);
|
|
17908
|
-
}
|
|
17909
|
-
for (const candidate of candidates) {
|
|
17910
|
-
const txId = findTransactionId(candidate);
|
|
17911
|
-
if (txId) {
|
|
17912
|
-
const withSameTxId = candidates.filter((row) => row[txId.field] === txId.value);
|
|
17913
|
-
if (withSameTxId.length === 1) {
|
|
17914
|
-
return withSameTxId[0];
|
|
17915
|
-
}
|
|
17916
|
-
}
|
|
17917
|
-
}
|
|
17918
|
-
const descriptionLower = posting.description.toLowerCase();
|
|
17919
|
-
const descMatches = candidates.filter((row) => {
|
|
17920
|
-
return Object.values(row).some((value) => value && value.toLowerCase().includes(descriptionLower));
|
|
17921
|
-
});
|
|
17922
|
-
if (descMatches.length === 1) {
|
|
17923
|
-
return descMatches[0];
|
|
17924
|
-
}
|
|
17925
|
-
if (descMatches.length > 1) {
|
|
17926
|
-
return descMatches[0];
|
|
17927
|
-
}
|
|
17928
|
-
return candidates[0];
|
|
17929
|
-
}
|
|
17930
|
-
|
|
17931
|
-
// src/tools/import-statements.ts
|
|
17932
|
-
function ensureYearJournalExists(directory, year) {
|
|
17933
|
-
const ledgerDir = path6.join(directory, "ledger");
|
|
17934
|
-
const yearJournalPath = path6.join(ledgerDir, `${year}.journal`);
|
|
17935
|
-
const mainJournalPath = path6.join(directory, ".hledger.journal");
|
|
17936
|
-
if (!fs7.existsSync(ledgerDir)) {
|
|
17937
|
-
fs7.mkdirSync(ledgerDir, { recursive: true });
|
|
17938
|
-
}
|
|
17939
|
-
if (!fs7.existsSync(yearJournalPath)) {
|
|
17940
|
-
fs7.writeFileSync(yearJournalPath, `; ${year} transactions
|
|
17941
|
-
`);
|
|
17942
|
-
}
|
|
17943
|
-
if (!fs7.existsSync(mainJournalPath)) {
|
|
17944
|
-
throw new Error(`.hledger.journal not found at ${mainJournalPath}. Create it first with appropriate includes.`);
|
|
17945
|
-
}
|
|
17946
|
-
const mainJournalContent = fs7.readFileSync(mainJournalPath, "utf-8");
|
|
17947
|
-
const includeDirective = `include ledger/${year}.journal`;
|
|
17948
|
-
const lines = mainJournalContent.split(`
|
|
17949
|
-
`);
|
|
17950
|
-
const includeExists = lines.some((line) => {
|
|
17951
|
-
const trimmed = line.trim();
|
|
17952
|
-
return trimmed === includeDirective || trimmed.startsWith(includeDirective + " ");
|
|
17953
|
-
});
|
|
17954
|
-
if (!includeExists) {
|
|
17955
|
-
const newContent = mainJournalContent.trimEnd() + `
|
|
17956
|
-
` + includeDirective + `
|
|
17957
|
-
`;
|
|
17958
|
-
fs7.writeFileSync(mainJournalPath, newContent);
|
|
17959
|
-
}
|
|
17960
|
-
return yearJournalPath;
|
|
17961
|
-
}
|
|
17962
|
-
function findPendingCsvFiles(pendingDir, provider, currency) {
|
|
17963
|
-
const csvFiles = [];
|
|
17964
|
-
if (!fs7.existsSync(pendingDir)) {
|
|
17965
|
-
return csvFiles;
|
|
17966
|
-
}
|
|
17967
|
-
let searchPath = pendingDir;
|
|
17968
|
-
if (provider) {
|
|
17969
|
-
searchPath = path6.join(searchPath, provider);
|
|
17970
|
-
if (currency) {
|
|
17971
|
-
searchPath = path6.join(searchPath, currency);
|
|
17972
|
-
}
|
|
17973
|
-
}
|
|
17974
|
-
if (!fs7.existsSync(searchPath)) {
|
|
17975
|
-
return csvFiles;
|
|
17976
|
-
}
|
|
17977
|
-
function scanDirectory(directory) {
|
|
17978
|
-
const entries = fs7.readdirSync(directory, { withFileTypes: true });
|
|
17979
|
-
for (const entry of entries) {
|
|
17980
|
-
const fullPath = path6.join(directory, entry.name);
|
|
17981
|
-
if (entry.isDirectory()) {
|
|
17982
|
-
scanDirectory(fullPath);
|
|
17983
|
-
} else if (entry.isFile() && entry.name.endsWith(".csv")) {
|
|
17984
|
-
csvFiles.push(fullPath);
|
|
17985
|
-
}
|
|
17986
|
-
}
|
|
17987
|
-
}
|
|
17988
|
-
scanDirectory(searchPath);
|
|
17989
|
-
return csvFiles.sort();
|
|
17990
|
-
}
|
|
17991
|
-
async function importStatementsCore(directory, agent, options, configLoader = loadImportConfig, hledgerExecutor = defaultHledgerExecutor) {
|
|
17992
|
-
if (agent !== "accountant") {
|
|
17993
|
-
return JSON.stringify({
|
|
17994
|
-
success: false,
|
|
17995
|
-
error: `This tool is restricted to the accountant agent only. Called by: ${agent || "main assistant"}`,
|
|
17996
|
-
hint: "Use: Task(subagent_type='accountant', prompt='import statements')"
|
|
17997
|
-
});
|
|
17998
|
-
}
|
|
17999
|
-
let config2;
|
|
18000
|
-
try {
|
|
18001
|
-
config2 = configLoader(directory);
|
|
18002
|
-
} catch (error45) {
|
|
18003
|
-
return JSON.stringify({
|
|
18004
|
-
success: false,
|
|
18005
|
-
error: `Failed to load configuration: ${error45 instanceof Error ? error45.message : String(error45)}`,
|
|
18006
|
-
hint: 'Ensure config/import/providers.yaml exists with required paths including "rules"'
|
|
18007
|
-
});
|
|
18008
|
-
}
|
|
18009
|
-
const pendingDir = path6.join(directory, config2.paths.pending);
|
|
18010
|
-
const rulesDir = path6.join(directory, config2.paths.rules);
|
|
18011
|
-
const doneDir = path6.join(directory, config2.paths.done);
|
|
18012
|
-
const rulesMapping = loadRulesMapping(rulesDir);
|
|
18013
|
-
const csvFiles = findPendingCsvFiles(pendingDir, options.provider, options.currency);
|
|
18014
|
-
if (csvFiles.length === 0) {
|
|
18015
|
-
return JSON.stringify({
|
|
18016
|
-
success: true,
|
|
18017
|
-
files: [],
|
|
18018
|
-
summary: {
|
|
18019
|
-
filesProcessed: 0,
|
|
18020
|
-
filesWithErrors: 0,
|
|
18021
|
-
filesWithoutRules: 0,
|
|
18022
|
-
totalTransactions: 0,
|
|
18023
|
-
matched: 0,
|
|
18024
|
-
unknown: 0
|
|
18025
|
-
},
|
|
18026
|
-
message: "No CSV files found to process"
|
|
18027
|
-
});
|
|
18028
|
-
}
|
|
18029
|
-
const fileResults = [];
|
|
18030
|
-
let totalTransactions = 0;
|
|
18031
|
-
let totalMatched = 0;
|
|
18032
|
-
let totalUnknown = 0;
|
|
18033
|
-
let filesWithErrors = 0;
|
|
18034
|
-
let filesWithoutRules = 0;
|
|
18035
|
-
for (const csvFile of csvFiles) {
|
|
18036
|
-
const rulesFile = findRulesForCsv(csvFile, rulesMapping);
|
|
18037
|
-
if (!rulesFile) {
|
|
18038
|
-
filesWithoutRules++;
|
|
18039
|
-
fileResults.push({
|
|
18040
|
-
csv: path6.relative(directory, csvFile),
|
|
18041
|
-
rulesFile: null,
|
|
18042
|
-
totalTransactions: 0,
|
|
18043
|
-
matchedTransactions: 0,
|
|
18044
|
-
unknownPostings: [],
|
|
18045
|
-
error: "No matching rules file found"
|
|
18046
|
-
});
|
|
18047
|
-
continue;
|
|
18048
|
-
}
|
|
18049
|
-
const result = await hledgerExecutor(["print", "-f", csvFile, "--rules-file", rulesFile]);
|
|
18050
|
-
if (result.exitCode !== 0) {
|
|
18051
|
-
filesWithErrors++;
|
|
18052
|
-
fileResults.push({
|
|
18053
|
-
csv: path6.relative(directory, csvFile),
|
|
18054
|
-
rulesFile: path6.relative(directory, rulesFile),
|
|
18055
|
-
totalTransactions: 0,
|
|
18056
|
-
matchedTransactions: 0,
|
|
18057
|
-
unknownPostings: [],
|
|
18058
|
-
error: `hledger error: ${result.stderr.trim() || "Unknown error"}`
|
|
18059
|
-
});
|
|
18060
|
-
continue;
|
|
18061
|
-
}
|
|
18062
|
-
const unknownPostings = parseUnknownPostings(result.stdout);
|
|
18063
|
-
const transactionCount = countTransactions(result.stdout);
|
|
18064
|
-
const matchedCount = transactionCount - unknownPostings.length;
|
|
18065
|
-
const years = extractTransactionYears(result.stdout);
|
|
18066
|
-
if (years.size > 1) {
|
|
18067
|
-
const yearList = Array.from(years).sort().join(", ");
|
|
18068
|
-
filesWithErrors++;
|
|
18069
|
-
fileResults.push({
|
|
18070
|
-
csv: path6.relative(directory, csvFile),
|
|
18071
|
-
rulesFile: path6.relative(directory, rulesFile),
|
|
18072
|
-
totalTransactions: transactionCount,
|
|
18073
|
-
matchedTransactions: matchedCount,
|
|
18074
|
-
unknownPostings: [],
|
|
18075
|
-
error: `CSV contains transactions from multiple years (${yearList}). Split the CSV by year before importing.`
|
|
18076
|
-
});
|
|
18077
|
-
continue;
|
|
18078
|
-
}
|
|
18079
|
-
const transactionYear = years.size === 1 ? Array.from(years)[0] : undefined;
|
|
18080
|
-
if (unknownPostings.length > 0) {
|
|
18081
|
-
try {
|
|
18082
|
-
const rulesContent = fs7.readFileSync(rulesFile, "utf-8");
|
|
18083
|
-
const rulesConfig = parseRulesFile(rulesContent);
|
|
18084
|
-
const csvRows = parseCsvFile(csvFile, rulesConfig);
|
|
18085
|
-
for (const posting of unknownPostings) {
|
|
18086
|
-
posting.csvRow = findMatchingCsvRow({
|
|
18087
|
-
date: posting.date,
|
|
18088
|
-
description: posting.description,
|
|
18089
|
-
amount: posting.amount
|
|
18090
|
-
}, csvRows, rulesConfig);
|
|
18091
|
-
}
|
|
18092
|
-
} catch {
|
|
18093
|
-
for (const posting of unknownPostings) {
|
|
18094
|
-
posting.csvRow = undefined;
|
|
18095
|
-
}
|
|
18096
|
-
}
|
|
18097
|
-
}
|
|
18098
|
-
totalTransactions += transactionCount;
|
|
18099
|
-
totalMatched += matchedCount;
|
|
18100
|
-
totalUnknown += unknownPostings.length;
|
|
18101
|
-
fileResults.push({
|
|
18102
|
-
csv: path6.relative(directory, csvFile),
|
|
18103
|
-
rulesFile: path6.relative(directory, rulesFile),
|
|
18104
|
-
totalTransactions: transactionCount,
|
|
18105
|
-
matchedTransactions: matchedCount,
|
|
18106
|
-
unknownPostings,
|
|
18107
|
-
transactionYear
|
|
18108
|
-
});
|
|
18109
|
-
}
|
|
18110
|
-
const hasUnknowns = totalUnknown > 0;
|
|
18111
|
-
const hasErrors = filesWithErrors > 0 || filesWithoutRules > 0;
|
|
18112
|
-
if (options.checkOnly !== false) {
|
|
18113
|
-
const result = {
|
|
18114
|
-
success: !hasUnknowns && !hasErrors,
|
|
18115
|
-
files: fileResults,
|
|
18116
|
-
summary: {
|
|
18117
|
-
filesProcessed: csvFiles.length,
|
|
18118
|
-
filesWithErrors,
|
|
18119
|
-
filesWithoutRules,
|
|
18120
|
-
totalTransactions,
|
|
18121
|
-
matched: totalMatched,
|
|
18122
|
-
unknown: totalUnknown
|
|
18123
|
-
}
|
|
18124
|
-
};
|
|
18125
|
-
if (hasUnknowns) {
|
|
18126
|
-
result.message = `Found ${totalUnknown} transaction(s) with unknown accounts. Add rules to categorize them.`;
|
|
18127
|
-
} else if (hasErrors) {
|
|
18128
|
-
result.message = `Some files had errors. Check the file results for details.`;
|
|
18129
|
-
} else {
|
|
18130
|
-
result.message = "All transactions matched. Ready to import with checkOnly: false";
|
|
18131
|
-
}
|
|
18132
|
-
return JSON.stringify(result);
|
|
18133
|
-
}
|
|
18134
|
-
if (hasUnknowns || hasErrors) {
|
|
18135
|
-
return JSON.stringify({
|
|
18136
|
-
success: false,
|
|
18137
|
-
files: fileResults,
|
|
18138
|
-
summary: {
|
|
18139
|
-
filesProcessed: csvFiles.length,
|
|
18140
|
-
filesWithErrors,
|
|
18141
|
-
filesWithoutRules,
|
|
18142
|
-
totalTransactions,
|
|
18143
|
-
matched: totalMatched,
|
|
18144
|
-
unknown: totalUnknown
|
|
18145
|
-
},
|
|
18146
|
-
error: "Cannot import: some transactions have unknown accounts or files have errors",
|
|
18147
|
-
hint: "Run with checkOnly: true to see details, then add missing rules"
|
|
18148
|
-
});
|
|
18149
|
-
}
|
|
18150
|
-
const importedFiles = [];
|
|
18151
|
-
for (const fileResult of fileResults) {
|
|
18152
|
-
const csvFile = path6.join(directory, fileResult.csv);
|
|
18153
|
-
const rulesFile = fileResult.rulesFile ? path6.join(directory, fileResult.rulesFile) : null;
|
|
18154
|
-
if (!rulesFile)
|
|
18155
|
-
continue;
|
|
18156
|
-
const year = fileResult.transactionYear;
|
|
18157
|
-
if (!year) {
|
|
18158
|
-
return JSON.stringify({
|
|
18159
|
-
success: false,
|
|
18160
|
-
files: fileResults,
|
|
18161
|
-
summary: {
|
|
18162
|
-
filesProcessed: csvFiles.length,
|
|
18163
|
-
filesWithErrors: 1,
|
|
18164
|
-
filesWithoutRules,
|
|
18165
|
-
totalTransactions,
|
|
18166
|
-
matched: totalMatched,
|
|
18167
|
-
unknown: totalUnknown
|
|
18168
|
-
},
|
|
18169
|
-
error: `No transactions found in ${fileResult.csv}`
|
|
18170
|
-
});
|
|
18171
|
-
}
|
|
18172
|
-
let yearJournalPath;
|
|
18173
|
-
try {
|
|
18174
|
-
yearJournalPath = ensureYearJournalExists(directory, year);
|
|
18175
|
-
} catch (error45) {
|
|
18176
|
-
return JSON.stringify({
|
|
18177
|
-
success: false,
|
|
18178
|
-
files: fileResults,
|
|
18179
|
-
summary: {
|
|
18180
|
-
filesProcessed: csvFiles.length,
|
|
18181
|
-
filesWithErrors: 1,
|
|
18182
|
-
filesWithoutRules,
|
|
18183
|
-
totalTransactions,
|
|
18184
|
-
matched: totalMatched,
|
|
18185
|
-
unknown: totalUnknown
|
|
18186
|
-
},
|
|
18187
|
-
error: error45 instanceof Error ? error45.message : String(error45)
|
|
18188
|
-
});
|
|
18189
|
-
}
|
|
18190
|
-
const result = await hledgerExecutor([
|
|
18191
|
-
"import",
|
|
18192
|
-
"-f",
|
|
18193
|
-
yearJournalPath,
|
|
18194
|
-
csvFile,
|
|
18195
|
-
"--rules-file",
|
|
18196
|
-
rulesFile
|
|
18197
|
-
]);
|
|
18198
|
-
if (result.exitCode !== 0) {
|
|
18199
|
-
return JSON.stringify({
|
|
18200
|
-
success: false,
|
|
18201
|
-
files: fileResults,
|
|
18202
|
-
summary: {
|
|
18203
|
-
filesProcessed: csvFiles.length,
|
|
18204
|
-
filesWithErrors: 1,
|
|
18205
|
-
filesWithoutRules,
|
|
18206
|
-
totalTransactions,
|
|
18207
|
-
matched: totalMatched,
|
|
18208
|
-
unknown: totalUnknown
|
|
18209
|
-
},
|
|
18210
|
-
error: `Import failed for ${fileResult.csv}: ${result.stderr.trim()}`
|
|
18211
|
-
});
|
|
18212
|
-
}
|
|
18213
|
-
importedFiles.push(csvFile);
|
|
18214
|
-
}
|
|
18215
|
-
const mainJournalPath = path6.join(directory, ".hledger.journal");
|
|
18216
|
-
const validationResult = await validateLedger(mainJournalPath, hledgerExecutor);
|
|
18217
|
-
if (!validationResult.valid) {
|
|
18218
|
-
return JSON.stringify({
|
|
18219
|
-
success: false,
|
|
18220
|
-
files: fileResults,
|
|
18221
|
-
summary: {
|
|
18222
|
-
filesProcessed: csvFiles.length,
|
|
18223
|
-
filesWithErrors: 1,
|
|
18224
|
-
filesWithoutRules,
|
|
18225
|
-
totalTransactions,
|
|
18226
|
-
matched: totalMatched,
|
|
18227
|
-
unknown: totalUnknown
|
|
18228
|
-
},
|
|
18229
|
-
error: `Ledger validation failed after import: ${validationResult.errors.join("; ")}`,
|
|
18230
|
-
hint: "The import created invalid transactions. Check your rules file configuration (e.g., balance vs balance2 for balance assertions). CSV files have NOT been moved to done."
|
|
18231
|
-
});
|
|
18232
|
-
}
|
|
18233
|
-
for (const csvFile of importedFiles) {
|
|
18234
|
-
const relativePath = path6.relative(pendingDir, csvFile);
|
|
18235
|
-
const destPath = path6.join(doneDir, relativePath);
|
|
18236
|
-
const destDir = path6.dirname(destPath);
|
|
18237
|
-
if (!fs7.existsSync(destDir)) {
|
|
18238
|
-
fs7.mkdirSync(destDir, { recursive: true });
|
|
18239
|
-
}
|
|
18240
|
-
fs7.renameSync(csvFile, destPath);
|
|
18241
|
-
}
|
|
18242
|
-
return JSON.stringify({
|
|
18243
|
-
success: true,
|
|
18244
|
-
files: fileResults.map((f) => ({
|
|
18245
|
-
...f,
|
|
18246
|
-
imported: true
|
|
18247
|
-
})),
|
|
18248
|
-
summary: {
|
|
18249
|
-
filesProcessed: csvFiles.length,
|
|
18250
|
-
filesWithErrors: 0,
|
|
18251
|
-
filesWithoutRules: 0,
|
|
18252
|
-
totalTransactions,
|
|
18253
|
-
matched: totalMatched,
|
|
18254
|
-
unknown: 0
|
|
18255
|
-
},
|
|
18256
|
-
message: `Successfully imported ${totalTransactions} transaction(s) from ${importedFiles.length} file(s)`
|
|
18257
|
-
});
|
|
18258
|
-
}
|
|
18259
|
-
var import_statements_default = tool({
|
|
18260
|
-
description: `Import classified bank statement CSVs into hledger using rules files.
|
|
18261
|
-
|
|
18262
|
-
This tool processes CSV files in the pending import directory and uses hledger's CSV import capabilities with matching rules files.
|
|
18263
|
-
|
|
18264
|
-
**Check Mode (checkOnly: true, default):**
|
|
18265
|
-
- Runs hledger print to preview transactions
|
|
18266
|
-
- Identifies transactions with 'income:unknown' or 'expenses:unknown' accounts
|
|
18267
|
-
- These indicate missing rules that need to be added
|
|
18268
|
-
|
|
18269
|
-
**Import Mode (checkOnly: false):**
|
|
18270
|
-
- First validates all transactions have known accounts
|
|
18271
|
-
- If any unknowns exist, aborts and reports them
|
|
18272
|
-
- If all clean, imports transactions and moves CSVs to done directory
|
|
18273
|
-
|
|
18274
|
-
**Workflow:**
|
|
18275
|
-
1. Run with checkOnly: true (or no args)
|
|
18276
|
-
2. If unknowns found, add rules to the appropriate .rules file
|
|
18277
|
-
3. Repeat until no unknowns
|
|
18278
|
-
4. Run with checkOnly: false to import`,
|
|
18279
|
-
args: {
|
|
18280
|
-
provider: tool.schema.string().optional().describe('Filter by provider (e.g., "revolut", "ubs"). If omitted, process all providers.'),
|
|
18281
|
-
currency: tool.schema.string().optional().describe('Filter by currency (e.g., "chf", "eur"). If omitted, process all currencies for the provider.'),
|
|
18282
|
-
checkOnly: tool.schema.boolean().optional().describe("If true (default), only check for unknown accounts without importing. Set to false to perform actual import.")
|
|
18283
|
-
},
|
|
18284
|
-
async execute(params, context) {
|
|
18285
|
-
const { directory, agent } = context;
|
|
18286
|
-
return importStatementsCore(directory, agent, {
|
|
18287
|
-
provider: params.provider,
|
|
18288
|
-
currency: params.currency,
|
|
18289
|
-
checkOnly: params.checkOnly
|
|
18290
|
-
});
|
|
18291
|
-
}
|
|
18292
|
-
});
|
|
18293
17195
|
// src/index.ts
|
|
18294
|
-
var __dirname2 =
|
|
18295
|
-
var AGENT_FILE =
|
|
17196
|
+
var __dirname2 = dirname(fileURLToPath(import.meta.url));
|
|
17197
|
+
var AGENT_FILE = join3(__dirname2, "..", "agent", "accountant.md");
|
|
18296
17198
|
var AccountantPlugin = async () => {
|
|
18297
17199
|
const agent = loadAgent(AGENT_FILE);
|
|
18298
17200
|
return {
|
|
18299
17201
|
tool: {
|
|
18300
17202
|
"update-prices": update_prices_default,
|
|
18301
|
-
"classify-statements":
|
|
18302
|
-
"import-statements":
|
|
17203
|
+
"classify-statements": classifyStatements,
|
|
17204
|
+
"import-statements": importStatements
|
|
18303
17205
|
},
|
|
18304
17206
|
config: async (config2) => {
|
|
18305
17207
|
if (agent) {
|