@morroc/open-ledger 0.14.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/LICENSE +202 -0
- package/README.md +191 -0
- package/datasets/defaults/cn.json +1 -0
- package/datasets/defaults/jp.json +1 -0
- package/datasets/defaults/th.json +1 -0
- package/datasets/defaults/us.json +1 -0
- package/datasets/institutions/cn.json +47 -0
- package/datasets/institutions/jp.json +59 -0
- package/datasets/institutions/th.json +99 -0
- package/datasets/institutions/us.json +70 -0
- package/dist/accounts/accounts.js +99 -0
- package/dist/accounts/balances.js +109 -0
- package/dist/accounts/matching.js +72 -0
- package/dist/accounts/resolve.js +118 -0
- package/dist/cli/commands/accounts.js +477 -0
- package/dist/cli/commands/config.js +175 -0
- package/dist/cli/commands/datasets.js +56 -0
- package/dist/cli/commands/doctor.js +153 -0
- package/dist/cli/commands/files.js +72 -0
- package/dist/cli/commands/ingest-commit.js +279 -0
- package/dist/cli/commands/ingest.js +203 -0
- package/dist/cli/commands/merchants.js +140 -0
- package/dist/cli/commands/notes.js +71 -0
- package/dist/cli/commands/open.js +54 -0
- package/dist/cli/commands/questions.js +119 -0
- package/dist/cli/commands/report.js +50 -0
- package/dist/cli/commands/setup.js +61 -0
- package/dist/cli/commands/status.js +187 -0
- package/dist/cli/commands/transactions.js +420 -0
- package/dist/cli/commands/vault.js +65 -0
- package/dist/cli/currency.js +28 -0
- package/dist/cli/db.js +5 -0
- package/dist/cli/format.js +71 -0
- package/dist/cli/index.js +19 -0
- package/dist/cli/output.js +304 -0
- package/dist/cli/program.js +140 -0
- package/dist/config.js +104 -0
- package/dist/context.js +30 -0
- package/dist/datasets/defaults.js +29 -0
- package/dist/datasets/index.js +33 -0
- package/dist/datasets/institutions.js +31 -0
- package/dist/datasets/loader.js +57 -0
- package/dist/db/connection.js +39 -0
- package/dist/db/encryption.js +42 -0
- package/dist/db/migrations/0001_baseline.js +121 -0
- package/dist/db/migrations/index.js +3 -0
- package/dist/db/queries/accounts.js +108 -0
- package/dist/db/queries/balances.js +61 -0
- package/dist/db/queries/files.js +72 -0
- package/dist/db/queries/merchants.js +163 -0
- package/dist/db/queries/notes.js +28 -0
- package/dist/db/queries/questions.js +117 -0
- package/dist/db/queries/transactions-dedup.js +80 -0
- package/dist/db/queries/transactions.js +319 -0
- package/dist/db/queries/vault.js +48 -0
- package/dist/db/schema.js +105 -0
- package/dist/extract/extract.js +89 -0
- package/dist/extract/ocr.js +164 -0
- package/dist/extract/pdf.js +127 -0
- package/dist/extract/presets/index.js +18 -0
- package/dist/extract/presets/lighton-ocr.js +12 -0
- package/dist/extract/presets/typhoon-ocr.js +23 -0
- package/dist/extract/route.js +40 -0
- package/dist/extract/source.js +84 -0
- package/dist/ingest/commit.js +313 -0
- package/dist/ingest/dedup.js +35 -0
- package/dist/ingest/prepare.js +241 -0
- package/dist/ingest/vault.js +56 -0
- package/dist/lib/date.js +6 -0
- package/dist/lib/ids.js +25 -0
- package/dist/lib/json.js +12 -0
- package/dist/lib/masked.js +49 -0
- package/dist/lib/money.js +35 -0
- package/dist/lib/patch.js +29 -0
- package/dist/lib/result.js +15 -0
- package/dist/lib/validate.js +154 -0
- package/dist/privacy/redactor.js +140 -0
- package/dist/setup/hosts.js +32 -0
- package/dist/setup/install.js +60 -0
- package/package.json +60 -0
- package/skills/SKILL.md +18 -0
package/dist/lib/json.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { tryExecute } from "./result.js";
|
|
2
|
+
/**
|
|
3
|
+
* For DB/JSON-column reads where a missing or corrupt blob should degrade to
|
|
4
|
+
* "nothing". Callers that must distinguish absent from corrupt should parse
|
|
5
|
+
* explicitly and branch instead.
|
|
6
|
+
*/
|
|
7
|
+
export function parseJsonOrNull(raw) {
|
|
8
|
+
if (raw == null)
|
|
9
|
+
return null;
|
|
10
|
+
const parsed = tryExecute(() => JSON.parse(raw));
|
|
11
|
+
return parsed.ok ? parsed.value : null;
|
|
12
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Masked account numbers: the canonical key behind both the stored form
|
|
3
|
+
* (`accounts.account_number_masked`) and the fuzzy matcher's number comparison,
|
|
4
|
+
* so a statement's `••7652-0` and `470686XXXXXX9483` resolve like a human reads
|
|
5
|
+
* them.
|
|
6
|
+
*/
|
|
7
|
+
// Characters statements use to blank out the hidden middle of an account
|
|
8
|
+
// number (`470686XXXXXX9483`, `••7652`, `**1234`, `…9483`).
|
|
9
|
+
const MASK_CHARS = "Xx•*…";
|
|
10
|
+
/** Everything after the last mask char in `s` (or `s` unchanged if none), so a
|
|
11
|
+
* masked run like `XXXXXX` isn't confused with a check-digit separator. */
|
|
12
|
+
export function tailAfterMask(s) {
|
|
13
|
+
let lastAt = -1;
|
|
14
|
+
for (const ch of MASK_CHARS) {
|
|
15
|
+
const i = s.lastIndexOf(ch);
|
|
16
|
+
if (i > lastAt)
|
|
17
|
+
lastAt = i;
|
|
18
|
+
}
|
|
19
|
+
return lastAt === -1 ? s : s.slice(lastAt + 1);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Canonical key for an account number, tolerant of a trailing check digit
|
|
23
|
+
* (`xxx-7652-0` and `xxx-7652` both resolve to one account). Masked digits
|
|
24
|
+
* before the mask are stripped first via `tailAfterMask` so they can't
|
|
25
|
+
* corrupt the check-digit heuristic.
|
|
26
|
+
*/
|
|
27
|
+
export function accountNumberKey(raw) {
|
|
28
|
+
const tail = tailAfterMask(String(raw ?? ""));
|
|
29
|
+
const digits = tail.replace(/\D+/g, "");
|
|
30
|
+
if (!digits)
|
|
31
|
+
return "";
|
|
32
|
+
const core = digits.length >= 5 ? digits.slice(0, -1) : digits;
|
|
33
|
+
return core.slice(-4);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Normalizes for storage so a trailing check digit can't split one account
|
|
37
|
+
* into two (`••7652-0` and `••76520` both store as `••7652`). Preserves the
|
|
38
|
+
* leading mask prefix, defaulting to `••` when there isn't one to preserve.
|
|
39
|
+
*/
|
|
40
|
+
export function normalizeMaskedAccountNumber(masked) {
|
|
41
|
+
if (masked == null)
|
|
42
|
+
return null;
|
|
43
|
+
const s = String(masked);
|
|
44
|
+
const key = accountNumberKey(s);
|
|
45
|
+
if (!key)
|
|
46
|
+
return s;
|
|
47
|
+
const prefix = /^\D+/.exec(s)?.[0] ?? "••";
|
|
48
|
+
return prefix + key;
|
|
49
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `transactions` stores amounts as integers in the currency's smallest unit
|
|
3
|
+
* (THB satang, JPY has none, KWD has three) to avoid float drift. Decimal
|
|
4
|
+
* conversion happens only at the CLI/pipeline boundary.
|
|
5
|
+
*/
|
|
6
|
+
const exponentCache = new Map();
|
|
7
|
+
/**
|
|
8
|
+
* Resolved via Intl and memoized. Falls back to 2 on an unresolvable code,
|
|
9
|
+
* including empty/garbage input - which Intl rejects too.
|
|
10
|
+
*/
|
|
11
|
+
export function minorUnitExponent(currency) {
|
|
12
|
+
const code = currency.toUpperCase();
|
|
13
|
+
const cached = exponentCache.get(code);
|
|
14
|
+
if (cached !== undefined)
|
|
15
|
+
return cached;
|
|
16
|
+
let exp = 2;
|
|
17
|
+
try {
|
|
18
|
+
const resolved = new Intl.NumberFormat("en", {
|
|
19
|
+
style: "currency",
|
|
20
|
+
currency: code,
|
|
21
|
+
}).resolvedOptions();
|
|
22
|
+
exp = resolved.maximumFractionDigits ?? 2;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
exp = 2;
|
|
26
|
+
}
|
|
27
|
+
exponentCache.set(code, exp);
|
|
28
|
+
return exp;
|
|
29
|
+
}
|
|
30
|
+
export function toMinorUnits(decimal, currency) {
|
|
31
|
+
return Math.round(decimal * 10 ** minorUnitExponent(currency));
|
|
32
|
+
}
|
|
33
|
+
export function fromMinorUnits(minor, currency) {
|
|
34
|
+
return minor / 10 ** minorUnitExponent(currency);
|
|
35
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds `SET` fragments, params, and before/after snapshots for every `spec`
|
|
3
|
+
* key present in `patch`. A key participates only when `patch[key] !==
|
|
4
|
+
* undefined` (absent leaves the column untouched; explicit `null` binds SQL
|
|
5
|
+
* NULL). No `changed` flag: callers test `sets.length` themselves after
|
|
6
|
+
* appending any hand-written fields.
|
|
7
|
+
*/
|
|
8
|
+
export function buildPatch(spec, current, patch) {
|
|
9
|
+
const currentRecord = current;
|
|
10
|
+
const patchRecord = patch;
|
|
11
|
+
const sets = [];
|
|
12
|
+
const params = [];
|
|
13
|
+
const before = {};
|
|
14
|
+
const after = {};
|
|
15
|
+
for (const key of Object.keys(spec)) {
|
|
16
|
+
if (patchRecord[key] === undefined)
|
|
17
|
+
continue;
|
|
18
|
+
const field = spec[key];
|
|
19
|
+
const column = field.column ?? key;
|
|
20
|
+
const value = field.transform ? field.transform(patchRecord[key]) : patchRecord[key];
|
|
21
|
+
sets.push(`${column} = ?`);
|
|
22
|
+
// libsql cannot bind `undefined`; a transform should never produce one,
|
|
23
|
+
// but this keeps the "params never contain undefined" contract airtight.
|
|
24
|
+
params.push(value === undefined ? null : value);
|
|
25
|
+
before[key] = currentRecord[column];
|
|
26
|
+
after[key] = value;
|
|
27
|
+
}
|
|
28
|
+
return { sets, params, before, after };
|
|
29
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function errorMessage(err) {
|
|
2
|
+
return err instanceof Error ? err.message : String(err);
|
|
3
|
+
}
|
|
4
|
+
export function tryExecute(fn) {
|
|
5
|
+
try {
|
|
6
|
+
const value = fn();
|
|
7
|
+
if (value instanceof Promise) {
|
|
8
|
+
return value.then((v) => ({ ok: true, value: v }), (err) => ({ ok: false, error: errorMessage(err) }));
|
|
9
|
+
}
|
|
10
|
+
return { ok: true, value };
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
return { ok: false, error: errorMessage(err) };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
/** Thrown by `parseInput` on a failed parse. `src/lib/` has no dependency on
|
|
3
|
+
* `src/cli/`, so the CLI layer maps this to its own error/exit-code type. */
|
|
4
|
+
export class ValidationError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "ValidationError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
// Each preprocess below passes undefined through, so an absent required key
|
|
11
|
+
// surfaces as a missing-required issue rather than a coerced value.
|
|
12
|
+
const toStringInput = (value) => typeof value === "string" || value === undefined ? value : String(value);
|
|
13
|
+
/** Non-finite results pass the raw value through so z.number rejects it and the
|
|
14
|
+
* formatter echoes the original in `got "…"`; "" and null coerce to 0 via Number(). */
|
|
15
|
+
const toNumberInput = (value) => {
|
|
16
|
+
if (typeof value === "number")
|
|
17
|
+
return value;
|
|
18
|
+
const n = Number(value);
|
|
19
|
+
return Number.isFinite(n) ? n : value;
|
|
20
|
+
};
|
|
21
|
+
const toBooleanInput = (value) => {
|
|
22
|
+
if (value === "true")
|
|
23
|
+
return true;
|
|
24
|
+
if (value === "false")
|
|
25
|
+
return false;
|
|
26
|
+
return value;
|
|
27
|
+
};
|
|
28
|
+
export function str() {
|
|
29
|
+
return z.preprocess(toStringInput, z.string());
|
|
30
|
+
}
|
|
31
|
+
export function num() {
|
|
32
|
+
return z.preprocess(toNumberInput, z.number());
|
|
33
|
+
}
|
|
34
|
+
export function int() {
|
|
35
|
+
return z.preprocess(toNumberInput, z.number().int());
|
|
36
|
+
}
|
|
37
|
+
export function bool() {
|
|
38
|
+
return z.preprocess(toBooleanInput, z.boolean());
|
|
39
|
+
}
|
|
40
|
+
/** A parse failure raises a custom issue carrying the JSON.parse message. */
|
|
41
|
+
export function json() {
|
|
42
|
+
return z.unknown().transform((value, ctx) => {
|
|
43
|
+
if (typeof value !== "string")
|
|
44
|
+
return value;
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(value);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
ctx.addIssue({ code: "custom", message: err.message });
|
|
50
|
+
return z.NEVER;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function defaultLabel(key) {
|
|
55
|
+
return "--" + key.replace(/_/g, "-");
|
|
56
|
+
}
|
|
57
|
+
function toCamelCase(key) {
|
|
58
|
+
return key.replace(/_([a-zA-Z0-9])/g, (_, c) => c.toUpperCase());
|
|
59
|
+
}
|
|
60
|
+
function toSnakeCase(key) {
|
|
61
|
+
return key.replace(/[A-Z]/g, (c) => "_" + c.toLowerCase());
|
|
62
|
+
}
|
|
63
|
+
/** Auto-bridges commander's camelCase opts to the snake_case names specs are
|
|
64
|
+
* written in; first non-`undefined` candidate wins. */
|
|
65
|
+
function resolveRaw(raw, key, aliases) {
|
|
66
|
+
const candidates = [key, toCamelCase(key), toSnakeCase(key), ...aliases];
|
|
67
|
+
for (const candidate of candidates) {
|
|
68
|
+
const value = raw[candidate];
|
|
69
|
+
if (value !== undefined)
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
// Absent keys are omitted so zod's optional/default/required handling stays authoritative.
|
|
75
|
+
function normalizeRaw(shape, raw, aliases = {}) {
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const key of Object.keys(shape)) {
|
|
78
|
+
const value = resolveRaw(raw, key, aliases[key] ?? []);
|
|
79
|
+
if (value !== undefined)
|
|
80
|
+
out[key] = value;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/** Render one non-missing issue into the pinned `<label> <constraint>` clause,
|
|
85
|
+
* echoing the raw pre-coercion value in `got "…"`. */
|
|
86
|
+
function constraintClause(label, issue, raw) {
|
|
87
|
+
if (issue.code === "custom")
|
|
88
|
+
return `${label} must be valid JSON: ${issue.message}`;
|
|
89
|
+
if (issue.code === "invalid_value") {
|
|
90
|
+
return `${label} must be one of ${(issue.values ?? []).join(", ")}, got "${String(raw)}"`;
|
|
91
|
+
}
|
|
92
|
+
if (issue.expected === "int")
|
|
93
|
+
return `${label} must be an integer, got "${String(raw)}"`;
|
|
94
|
+
if (issue.expected === "number")
|
|
95
|
+
return `${label} must be a number, got "${String(raw)}"`;
|
|
96
|
+
if (issue.expected === "boolean")
|
|
97
|
+
return `${label} must be a boolean, got "${String(raw)}"`;
|
|
98
|
+
return `${label} ${issue.message}`;
|
|
99
|
+
}
|
|
100
|
+
/** An issue whose normalized value is `undefined` is a missing-required field.
|
|
101
|
+
* All-missing groups as `--a, --b required`; otherwise clauses join with "; ". */
|
|
102
|
+
function formatError(shape, normalized, issues, opts) {
|
|
103
|
+
const first = new Map();
|
|
104
|
+
for (const issue of issues) {
|
|
105
|
+
const key = String(issue.path[0]);
|
|
106
|
+
if (!first.has(key))
|
|
107
|
+
first.set(key, issue);
|
|
108
|
+
}
|
|
109
|
+
const missing = [];
|
|
110
|
+
const clauses = [];
|
|
111
|
+
let hasConstraint = false;
|
|
112
|
+
for (const key of Object.keys(shape)) {
|
|
113
|
+
const issue = first.get(key);
|
|
114
|
+
if (!issue)
|
|
115
|
+
continue;
|
|
116
|
+
const label = opts?.labels?.[key] ?? defaultLabel(key);
|
|
117
|
+
if (normalized[key] === undefined) {
|
|
118
|
+
missing.push(label);
|
|
119
|
+
clauses.push(`${label} required`);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
hasConstraint = true;
|
|
123
|
+
clauses.push(constraintClause(label, issue, normalized[key]));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (!hasConstraint)
|
|
127
|
+
return `${missing.join(", ")} required`;
|
|
128
|
+
return clauses.join("; ");
|
|
129
|
+
}
|
|
130
|
+
function parse(schema, raw, opts) {
|
|
131
|
+
const shape = schema.shape;
|
|
132
|
+
const normalized = normalizeRaw(shape, raw, opts?.aliases);
|
|
133
|
+
const parsed = schema.safeParse(normalized);
|
|
134
|
+
if (parsed.success)
|
|
135
|
+
return { ok: true, value: parsed.data };
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
error: formatError(shape, normalized, parsed.error.issues, opts),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** Accumulates every missing-required and coercion error into one thrown `ValidationError`. */
|
|
142
|
+
export function parseInput(schema, raw, opts) {
|
|
143
|
+
const result = parse(schema, raw, opts);
|
|
144
|
+
if (!result.ok)
|
|
145
|
+
throw new ValidationError(result.error);
|
|
146
|
+
if (opts?.atLeastOne && Object.keys(result.value).length === 0) {
|
|
147
|
+
throw new ValidationError(opts.atLeastOne);
|
|
148
|
+
}
|
|
149
|
+
return result.value;
|
|
150
|
+
}
|
|
151
|
+
/** For batch rows, which must keep the PARTIAL exit-code contract instead of throwing. */
|
|
152
|
+
export function safeParse(schema, raw, opts) {
|
|
153
|
+
return parse(schema, raw, opts);
|
|
154
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
import { readContext } from "../context.js";
|
|
3
|
+
const SECTION_RULES = [
|
|
4
|
+
{
|
|
5
|
+
heading: "Family",
|
|
6
|
+
token: "[PARTNER]",
|
|
7
|
+
stripParen: true,
|
|
8
|
+
skipIfUser: true,
|
|
9
|
+
patterns: [
|
|
10
|
+
/^(?:partner|spouse|wife|husband|child|kid|son|daughter|dependent)[:\s]+(.+)/i,
|
|
11
|
+
/^([\p{Lu}\p{Lo}][\p{L}\s]+)/u,
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
heading: "Income",
|
|
16
|
+
token: "[EMPLOYER]",
|
|
17
|
+
patterns: [
|
|
18
|
+
/(?:employer|works? (?:at|for)|employed (?:at|by))[:\s]+([A-Z][\w\s&.,-]+?)(?:\s*[-–—|,;(\n]|$)/i,
|
|
19
|
+
/\bfrom ([A-Z][A-Za-z\s&.,-]+?)(?:\s*[-–—|,;(\n]|$)/,
|
|
20
|
+
/\bat ([A-Z][A-Za-z\s&.,-]+?)(?:\s*[-–—|,;(\n]|$)/,
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
const NUMERIC_PII_PATTERNS = [
|
|
25
|
+
// Thai national ID with dashes: 1-2345-67890-12-3
|
|
26
|
+
[/\b\d-\d{4}-\d{5}-\d{2}-\d\b/g, "[NATID]"],
|
|
27
|
+
// Thai national ID without dashes (13 digits): must precede the generic ACCT pattern.
|
|
28
|
+
[/\b\d{13}\b/g, "[NATID]"],
|
|
29
|
+
// Thai mobile numbers: 0[689]xxxxxxxx (10 digits starting 06/08/09)
|
|
30
|
+
[/\b0[689]\d{8}\b/g, "[PHONE]"],
|
|
31
|
+
// 16-digit credit card (with optional separators)
|
|
32
|
+
[/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g, "[CARD]"],
|
|
33
|
+
// 10-12 digit account / routing numbers at a word boundary
|
|
34
|
+
[/\b\d{10,12}\b(?=\s|$|[,.])/g, "[ACCT]"],
|
|
35
|
+
];
|
|
36
|
+
function extractSectionLines(context, heading) {
|
|
37
|
+
const re = new RegExp(`## ${heading}\\n([\\s\\S]*?)(?=\\n##|$)`);
|
|
38
|
+
const match = context.match(re);
|
|
39
|
+
if (!match)
|
|
40
|
+
return [];
|
|
41
|
+
return match[1]
|
|
42
|
+
.split("\n")
|
|
43
|
+
.filter((l) => l.trim().startsWith("-"))
|
|
44
|
+
.map((l) => l.replace(/^-\s*/, "").trim())
|
|
45
|
+
.filter((text) => text.length > 0 && !text.startsWith("("));
|
|
46
|
+
}
|
|
47
|
+
function applyRule(rule, context, userName, push) {
|
|
48
|
+
for (const line of extractSectionLines(context, rule.heading)) {
|
|
49
|
+
if (rule.skipIfUser && line.toLowerCase() === userName.toLowerCase())
|
|
50
|
+
continue;
|
|
51
|
+
for (const pattern of rule.patterns) {
|
|
52
|
+
const match = line.match(pattern);
|
|
53
|
+
if (!match)
|
|
54
|
+
continue;
|
|
55
|
+
let name = match[1].trim();
|
|
56
|
+
if (rule.stripParen)
|
|
57
|
+
name = name.replace(/\s*\(.*\)/, "").trim();
|
|
58
|
+
if (!name)
|
|
59
|
+
break;
|
|
60
|
+
if (rule.skipIfUser && name.toLowerCase() === userName.toLowerCase())
|
|
61
|
+
break;
|
|
62
|
+
push(name, rule.token);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function buildRedactions() {
|
|
68
|
+
const entries = [];
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
const push = (real, token) => {
|
|
71
|
+
const trimmed = real.trim();
|
|
72
|
+
if (trimmed.length < 2)
|
|
73
|
+
return;
|
|
74
|
+
const key = trimmed.toLowerCase();
|
|
75
|
+
if (seen.has(key))
|
|
76
|
+
return;
|
|
77
|
+
seen.add(key);
|
|
78
|
+
entries.push({ real: trimmed, token });
|
|
79
|
+
};
|
|
80
|
+
const userName = config.userName;
|
|
81
|
+
if (userName && userName !== "User") {
|
|
82
|
+
push(userName, "[USER]");
|
|
83
|
+
const parts = userName.split(/\s+/);
|
|
84
|
+
if (parts.length > 1) {
|
|
85
|
+
push(parts[0], "[USER_FIRST]");
|
|
86
|
+
push(parts[parts.length - 1], "[USER_LAST]");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const context = readContext();
|
|
90
|
+
if (context) {
|
|
91
|
+
for (const rule of SECTION_RULES) {
|
|
92
|
+
applyRule(rule, context, userName, push);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
entries.sort((a, b) => b.real.length - a.real.length);
|
|
96
|
+
return entries;
|
|
97
|
+
}
|
|
98
|
+
/** Builds name rules from config.userName + context.md once, returning a reusable
|
|
99
|
+
* masker that amortizes that cost across many values. */
|
|
100
|
+
function createRedactor() {
|
|
101
|
+
const redactions = buildRedactions();
|
|
102
|
+
return (text) => {
|
|
103
|
+
let result = text;
|
|
104
|
+
for (const { real, token } of redactions) {
|
|
105
|
+
result = result.replaceAll(real, token);
|
|
106
|
+
}
|
|
107
|
+
for (const [pattern, replacement] of NUMERIC_PII_PATTERNS) {
|
|
108
|
+
result = result.replace(pattern, replacement);
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Deep-walks `data`, redacting a string value only when its key is in the
|
|
115
|
+
* per-command `fields` allowlist — ids/enums/amounts the agent needs verbatim
|
|
116
|
+
* stay untouched. Returns a fresh structure (input untouched); a no-op when
|
|
117
|
+
* `enabled` is false.
|
|
118
|
+
*/
|
|
119
|
+
export function applyRedaction(data, enabled, fields) {
|
|
120
|
+
if (!enabled)
|
|
121
|
+
return data;
|
|
122
|
+
const redactor = createRedactor();
|
|
123
|
+
const allow = new Set(fields);
|
|
124
|
+
const walk = (value, key) => {
|
|
125
|
+
if (typeof value === "string") {
|
|
126
|
+
return key !== undefined && allow.has(key) ? redactor(value) : value;
|
|
127
|
+
}
|
|
128
|
+
if (Array.isArray(value)) {
|
|
129
|
+
return value.map((item) => walk(item, key));
|
|
130
|
+
}
|
|
131
|
+
if (value !== null && typeof value === "object") {
|
|
132
|
+
const out = {};
|
|
133
|
+
for (const [k, v] of Object.entries(value))
|
|
134
|
+
out[k] = walk(v, k);
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
return value;
|
|
138
|
+
};
|
|
139
|
+
return walk(data, undefined);
|
|
140
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
/**
|
|
4
|
+
* Any coding agent compatible with the shared .agents/skills dir (Codex,
|
|
5
|
+
* OpenCode, Pi, Kimi global, …) is served by this default entry; only hosts
|
|
6
|
+
* that cannot read it get their own.
|
|
7
|
+
*/
|
|
8
|
+
const agents = {
|
|
9
|
+
id: "agents",
|
|
10
|
+
label: "Agent Skills standard directory",
|
|
11
|
+
projectDir: ".agents/skills",
|
|
12
|
+
globalDir: () => resolve(homedir(), ".agents/skills"),
|
|
13
|
+
};
|
|
14
|
+
const claude = {
|
|
15
|
+
id: "claude",
|
|
16
|
+
label: "Claude Code",
|
|
17
|
+
projectDir: ".claude/skills",
|
|
18
|
+
globalDir: () => resolve(homedir(), ".claude/skills"),
|
|
19
|
+
};
|
|
20
|
+
// Kimi's project dir is its own .skills; its global reads the shared
|
|
21
|
+
// ~/.agents/skills, so the global install goes there.
|
|
22
|
+
const kimi = {
|
|
23
|
+
id: "kimi",
|
|
24
|
+
label: "Kimi",
|
|
25
|
+
projectDir: ".skills",
|
|
26
|
+
globalDir: () => resolve(homedir(), ".agents/skills"),
|
|
27
|
+
};
|
|
28
|
+
export const SKILL_HOSTS = [agents, claude, kimi];
|
|
29
|
+
export const DEFAULT_HOST = agents.id;
|
|
30
|
+
export function findHost(id) {
|
|
31
|
+
return SKILL_HOSTS.find((h) => h.id === id) ?? null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import { join, resolve } from "path";
|
|
4
|
+
import { findHost, DEFAULT_HOST } from "./hosts.js";
|
|
5
|
+
/** Thrown when an installed skill dir is at a DIFFERENT version and --force wasn't given;
|
|
6
|
+
* the CLI maps this to exit code INVALID with a --force hint. */
|
|
7
|
+
export class SkillPackVersionError extends Error {
|
|
8
|
+
installedVersion;
|
|
9
|
+
cliVersion;
|
|
10
|
+
path;
|
|
11
|
+
constructor(args) {
|
|
12
|
+
super(`skill pack already installed at ${args.path} (version ${args.installedVersion}); ` +
|
|
13
|
+
`this CLI is ${args.cliVersion}`);
|
|
14
|
+
this.name = "SkillPackVersionError";
|
|
15
|
+
this.installedVersion = args.installedVersion;
|
|
16
|
+
this.cliVersion = args.cliVersion;
|
|
17
|
+
this.path = args.path;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// install.ts compiles to dist/setup/install.js; ../../package.json from there is
|
|
21
|
+
// the package root (same 2-level depth as src/cli/index.ts uses).
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
export function getVersion() {
|
|
24
|
+
const { version } = require("../../package.json");
|
|
25
|
+
return version;
|
|
26
|
+
}
|
|
27
|
+
export function skillMd() {
|
|
28
|
+
return readFileSync(new URL("../../skills/SKILL.md", import.meta.url), "utf8");
|
|
29
|
+
}
|
|
30
|
+
// --dir D is host-agnostic: resolve(D)/skills/open-ledger. Otherwise <cwd or
|
|
31
|
+
// home>/<host skills dir>/open-ledger (the host dir already ends in skills).
|
|
32
|
+
function resolveTarget(opts) {
|
|
33
|
+
if (opts.dir) {
|
|
34
|
+
return { kind: "dir", dir: join(resolve(opts.dir), "skills", "open-ledger") };
|
|
35
|
+
}
|
|
36
|
+
const host = findHost(opts.host ?? DEFAULT_HOST);
|
|
37
|
+
if (!host)
|
|
38
|
+
throw new Error(`unknown skill host: ${opts.host}`);
|
|
39
|
+
const base = opts.global ? host.globalDir() : resolve(process.cwd(), host.projectDir);
|
|
40
|
+
return { kind: host.id, dir: join(base, "open-ledger") };
|
|
41
|
+
}
|
|
42
|
+
function readVersionFile(skillDir) {
|
|
43
|
+
const versionPath = join(skillDir, "VERSION");
|
|
44
|
+
if (!existsSync(versionPath))
|
|
45
|
+
return null;
|
|
46
|
+
return readFileSync(versionPath, "utf8").trim();
|
|
47
|
+
}
|
|
48
|
+
/** Idempotent at the same version; throws SkillPackVersionError on a clash without --force. */
|
|
49
|
+
export function installSkill(opts = {}) {
|
|
50
|
+
const version = getVersion();
|
|
51
|
+
const { kind, dir } = resolveTarget(opts);
|
|
52
|
+
const existing = readVersionFile(dir);
|
|
53
|
+
if (existing !== null && existing !== version && !opts.force) {
|
|
54
|
+
throw new SkillPackVersionError({ installedVersion: existing, cliVersion: version, path: dir });
|
|
55
|
+
}
|
|
56
|
+
mkdirSync(dir, { recursive: true });
|
|
57
|
+
writeFileSync(join(dir, "SKILL.md"), skillMd());
|
|
58
|
+
writeFileSync(join(dir, "VERSION"), version + "\n");
|
|
59
|
+
return { kind, path: dir, version };
|
|
60
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@morroc/open-ledger",
|
|
3
|
+
"version": "0.14.1",
|
|
4
|
+
"description": "OpenLedger — The Harness Layer for Personal Finance",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"finance",
|
|
7
|
+
"harness",
|
|
8
|
+
"personal",
|
|
9
|
+
"ledger",
|
|
10
|
+
"bookkeeping",
|
|
11
|
+
"cli",
|
|
12
|
+
"ai"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/phureewat29/open-ledger#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/phureewat29/open-ledger/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/phureewat29/open-ledger.git"
|
|
21
|
+
},
|
|
22
|
+
"license": "Apache-2.0",
|
|
23
|
+
"author": "Phureewat A",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"bin": {
|
|
26
|
+
"oled": "dist/cli/index.js"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist/",
|
|
30
|
+
"skills/",
|
|
31
|
+
"datasets/"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "rm -rf dist && tsc",
|
|
35
|
+
"test": "vitest run --typecheck --typecheck.tsconfig ./tsconfig.typecheck.json",
|
|
36
|
+
"dev": "tsx src/cli/index.ts",
|
|
37
|
+
"release": "tsx scripts/release.ts",
|
|
38
|
+
"integration": "npm run build && tsx scripts/integration.ts",
|
|
39
|
+
"prepublishOnly": "npm run build"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"chalk": "^5.3.0",
|
|
43
|
+
"commander": "^13.0.0",
|
|
44
|
+
"dotenv": "^16.4.0",
|
|
45
|
+
"libsql": "^0.5.0",
|
|
46
|
+
"mupdf": "^1.27.0",
|
|
47
|
+
"zod": "^4.4.3"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^22.0.0",
|
|
51
|
+
"@types/semver": "^7.7.1",
|
|
52
|
+
"semver": "^7.8.4",
|
|
53
|
+
"tsx": "^4.7.0",
|
|
54
|
+
"typescript": "^5.5.0",
|
|
55
|
+
"vitest": "^3.2.4"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=18"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/skills/SKILL.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: open-ledger
|
|
3
|
+
description: A local double-entry personal-finance harness driven through the `oled` cli. Use for anything about the ledger, bank or credit-card statements, net worth, spending, accounts, transactions, or merchants.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# OpenLedger
|
|
7
|
+
|
|
8
|
+
`oled` is a deterministic CLI over a local, encrypted, double-entry ledger; you supply the intelligence. The CLI is the manual: `oled --help` lists the commands and the output contract, `oled <noun> --help` gives each command's behavior, flow, and flags, and every error carries a code and a message, often a `hint`. When you do not understand something, ask the CLI before guessing — never invent flags, subcommands, or ids.
|
|
9
|
+
|
|
10
|
+
Pass `--json` on every command. With a shell, run `oled` yourself, one command per call. Human is your terminal: send one command per message, wait for the pasted output; ask for uploads instead of reading files.
|
|
11
|
+
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
`oled --version` prints a version when installed. To install: check `node --version` >= 18, then `npm install -g @morroc/open-ledger`. First run: `oled config --generate-key --json` unless `oled status --json` shows `"configured":true`. Statements go in the `dataDir` from `oled config show --json`, as PDFs or as photos/scans (PNG/JPEG/WebP); `oled open` opens it.
|
|
15
|
+
|
|
16
|
+
## Commands
|
|
17
|
+
|
|
18
|
+
Start with `oled status --json`; descriptions live in `oled --help`. commands: `oled doctor --json` · `oled setup --force` · `oled config show --json` · `oled ingest list --json` · `oled files list --json` · `oled vault add <pattern>` · `oled transactions list --json` · `oled accounts tree --json` · `oled merchants list --json` · `oled questions list --json` · `oled report --from <date> --to <date> --json` · `oled notes list --json` · `oled datasets --json` · `oled open`.
|