@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.
Files changed (81) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +191 -0
  3. package/datasets/defaults/cn.json +1 -0
  4. package/datasets/defaults/jp.json +1 -0
  5. package/datasets/defaults/th.json +1 -0
  6. package/datasets/defaults/us.json +1 -0
  7. package/datasets/institutions/cn.json +47 -0
  8. package/datasets/institutions/jp.json +59 -0
  9. package/datasets/institutions/th.json +99 -0
  10. package/datasets/institutions/us.json +70 -0
  11. package/dist/accounts/accounts.js +99 -0
  12. package/dist/accounts/balances.js +109 -0
  13. package/dist/accounts/matching.js +72 -0
  14. package/dist/accounts/resolve.js +118 -0
  15. package/dist/cli/commands/accounts.js +477 -0
  16. package/dist/cli/commands/config.js +175 -0
  17. package/dist/cli/commands/datasets.js +56 -0
  18. package/dist/cli/commands/doctor.js +153 -0
  19. package/dist/cli/commands/files.js +72 -0
  20. package/dist/cli/commands/ingest-commit.js +279 -0
  21. package/dist/cli/commands/ingest.js +203 -0
  22. package/dist/cli/commands/merchants.js +140 -0
  23. package/dist/cli/commands/notes.js +71 -0
  24. package/dist/cli/commands/open.js +54 -0
  25. package/dist/cli/commands/questions.js +119 -0
  26. package/dist/cli/commands/report.js +50 -0
  27. package/dist/cli/commands/setup.js +61 -0
  28. package/dist/cli/commands/status.js +187 -0
  29. package/dist/cli/commands/transactions.js +420 -0
  30. package/dist/cli/commands/vault.js +65 -0
  31. package/dist/cli/currency.js +28 -0
  32. package/dist/cli/db.js +5 -0
  33. package/dist/cli/format.js +71 -0
  34. package/dist/cli/index.js +19 -0
  35. package/dist/cli/output.js +304 -0
  36. package/dist/cli/program.js +140 -0
  37. package/dist/config.js +104 -0
  38. package/dist/context.js +30 -0
  39. package/dist/datasets/defaults.js +29 -0
  40. package/dist/datasets/index.js +33 -0
  41. package/dist/datasets/institutions.js +31 -0
  42. package/dist/datasets/loader.js +57 -0
  43. package/dist/db/connection.js +39 -0
  44. package/dist/db/encryption.js +42 -0
  45. package/dist/db/migrations/0001_baseline.js +121 -0
  46. package/dist/db/migrations/index.js +3 -0
  47. package/dist/db/queries/accounts.js +108 -0
  48. package/dist/db/queries/balances.js +61 -0
  49. package/dist/db/queries/files.js +72 -0
  50. package/dist/db/queries/merchants.js +163 -0
  51. package/dist/db/queries/notes.js +28 -0
  52. package/dist/db/queries/questions.js +117 -0
  53. package/dist/db/queries/transactions-dedup.js +80 -0
  54. package/dist/db/queries/transactions.js +319 -0
  55. package/dist/db/queries/vault.js +48 -0
  56. package/dist/db/schema.js +105 -0
  57. package/dist/extract/extract.js +89 -0
  58. package/dist/extract/ocr.js +164 -0
  59. package/dist/extract/pdf.js +127 -0
  60. package/dist/extract/presets/index.js +18 -0
  61. package/dist/extract/presets/lighton-ocr.js +12 -0
  62. package/dist/extract/presets/typhoon-ocr.js +23 -0
  63. package/dist/extract/route.js +40 -0
  64. package/dist/extract/source.js +84 -0
  65. package/dist/ingest/commit.js +313 -0
  66. package/dist/ingest/dedup.js +35 -0
  67. package/dist/ingest/prepare.js +241 -0
  68. package/dist/ingest/vault.js +56 -0
  69. package/dist/lib/date.js +6 -0
  70. package/dist/lib/ids.js +25 -0
  71. package/dist/lib/json.js +12 -0
  72. package/dist/lib/masked.js +49 -0
  73. package/dist/lib/money.js +35 -0
  74. package/dist/lib/patch.js +29 -0
  75. package/dist/lib/result.js +15 -0
  76. package/dist/lib/validate.js +154 -0
  77. package/dist/privacy/redactor.js +140 -0
  78. package/dist/setup/hosts.js +32 -0
  79. package/dist/setup/install.js +60 -0
  80. package/package.json +60 -0
  81. package/skills/SKILL.md +18 -0
@@ -0,0 +1,164 @@
1
+ import * as z from "zod";
2
+ import { config } from "../config.js";
3
+ import { tryExecute } from "../lib/result.js";
4
+ import { presetForModel } from "./presets/index.js";
5
+ /**
6
+ * Nothing here is tied to a particular model: the endpoint is whatever
7
+ * `ocrBaseUrl` names, and the prompt, sampling, and render spec come from the
8
+ * preset the configured model id selects.
9
+ */
10
+ /** A small local model can spend minutes on a dense page; this is a stuck-server bound, not a target. */
11
+ export const OCR_TIMEOUT_MS = 300_000;
12
+ // The abort covers connect and body alike, but a stalled server has been seen
13
+ // outliving it, so a second bound races it.
14
+ const TIMEOUT_GRACE_MS = 5_000;
15
+ /** A liveness check, not a read: bounded independently of the per-page timeout. */
16
+ const PROBE_TIMEOUT_MS = 5_000;
17
+ const ERROR_BODY_CHARS = 200;
18
+ /** The endpoint URL alone decides whether OCR is configured; `null` means cleanly unset. */
19
+ export function resolveOcr(source = config) {
20
+ const baseUrl = source.ocrBaseUrl.trim().replace(/\/+$/, "");
21
+ if (!baseUrl)
22
+ return null;
23
+ const configured = source.ocrModel.trim();
24
+ const { name, preset } = presetForModel(configured);
25
+ return {
26
+ baseUrl,
27
+ model: configured || preset.model,
28
+ apiKey: source.ocrApiKey.trim(),
29
+ timeoutMs: OCR_TIMEOUT_MS,
30
+ preset: name,
31
+ prompt: preset.prompt,
32
+ params: preset.params,
33
+ render: preset.render,
34
+ };
35
+ }
36
+ // Exhaustive by construction: a new OcrFailure has to be classified here before
37
+ // it compiles.
38
+ const PAGE_LEVEL = {
39
+ timeout: true,
40
+ bad_response: true,
41
+ unreachable: false,
42
+ rejected: false,
43
+ };
44
+ /**
45
+ * Whether a failure is about this page or about the server. Page-level failures
46
+ * leave a placeholder and let the run finish partial; server-level ones abort —
47
+ * retrying 20 pages against a dead endpoint helps nobody.
48
+ */
49
+ export function isServerFailure(reason) {
50
+ return !PAGE_LEVEL[reason];
51
+ }
52
+ const RESPONSE = z.object({
53
+ choices: z.array(z.object({ message: z.object({ content: z.string() }) })).min(1),
54
+ });
55
+ const MODELS = z.object({ data: z.array(z.object({ id: z.string() })) });
56
+ function headersFor(settings) {
57
+ const headers = { "content-type": "application/json" };
58
+ // Local servers reject an empty bearer token, so the header appears only when there is a key.
59
+ if (settings.apiKey)
60
+ headers.authorization = `Bearer ${settings.apiKey}`;
61
+ return headers;
62
+ }
63
+ function requestBody(image, settings) {
64
+ return JSON.stringify({
65
+ model: settings.model,
66
+ messages: [
67
+ {
68
+ role: "user",
69
+ content: [
70
+ { type: "text", text: settings.prompt },
71
+ {
72
+ type: "image_url",
73
+ image_url: { url: `data:${image.mime};base64,${image.bytes.toString("base64")}` },
74
+ },
75
+ ],
76
+ },
77
+ ],
78
+ ...settings.params,
79
+ stream: false,
80
+ });
81
+ }
82
+ function failed(page, reason, message) {
83
+ return { ok: false, page, reason, message };
84
+ }
85
+ function clip(body) {
86
+ return body.length > ERROR_BODY_CHARS ? `${body.slice(0, ERROR_BODY_CHARS)}…` : body;
87
+ }
88
+ const TIMED_OUT = Symbol("ocr-timeout");
89
+ export async function ocrPage(image, settings) {
90
+ const controller = new AbortController();
91
+ const abort = setTimeout(() => controller.abort(), settings.timeoutMs);
92
+ const attempt = tryExecute(async () => {
93
+ const response = await fetch(`${settings.baseUrl}/chat/completions`, {
94
+ method: "POST",
95
+ headers: headersFor(settings),
96
+ body: requestBody(image, settings),
97
+ signal: controller.signal,
98
+ });
99
+ return { status: response.status, body: await response.text() };
100
+ });
101
+ let deadline;
102
+ const bound = new Promise((resolve) => {
103
+ deadline = setTimeout(() => resolve(TIMED_OUT), settings.timeoutMs + TIMEOUT_GRACE_MS);
104
+ deadline.unref();
105
+ });
106
+ const raced = await Promise.race([attempt, bound]);
107
+ clearTimeout(abort);
108
+ clearTimeout(deadline);
109
+ if (raced === TIMED_OUT) {
110
+ return failed(image.page, "timeout", `no response within ${settings.timeoutMs}ms`);
111
+ }
112
+ if (!raced.ok && controller.signal.aborted) {
113
+ return failed(image.page, "timeout", `aborted after ${settings.timeoutMs}ms`);
114
+ }
115
+ if (!raced.ok)
116
+ return failed(image.page, "unreachable", raced.error);
117
+ const { status, body } = raced.value;
118
+ if (status < 200 || status >= 300) {
119
+ return failed(image.page, "rejected", `HTTP ${status}: ${clip(body)}`);
120
+ }
121
+ const json = tryExecute(() => JSON.parse(body));
122
+ if (!json.ok)
123
+ return failed(image.page, "bad_response", json.error);
124
+ const parsed = RESPONSE.safeParse(json.value);
125
+ if (!parsed.success) {
126
+ return failed(image.page, "bad_response", z.prettifyError(parsed.error));
127
+ }
128
+ return { ok: true, page: image.page, text: parsed.data.choices[0].message.content };
129
+ }
130
+ /**
131
+ * One page per request, sequentially: the endpoint serves one model, so
132
+ * parallel pages queue behind each other and only eat into the per-page
133
+ * timeout. Stops early on a server-level failure; the caller reads the returned
134
+ * outcomes to see which pages made it.
135
+ */
136
+ export async function ocrPages(images, settings) {
137
+ const outcomes = [];
138
+ for (const image of images) {
139
+ const outcome = await ocrPage(image, settings);
140
+ outcomes.push(outcome);
141
+ if (!outcome.ok && isServerFailure(outcome.reason))
142
+ break;
143
+ }
144
+ return outcomes;
145
+ }
146
+ /** The model ids the endpoint serves, for `oled doctor`. */
147
+ export async function probeOcrEndpoint(settings) {
148
+ const response = await tryExecute(async () => {
149
+ const res = await fetch(`${settings.baseUrl}/models`, {
150
+ headers: headersFor(settings),
151
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
152
+ });
153
+ if (!res.ok)
154
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
155
+ return res.json();
156
+ });
157
+ if (!response.ok)
158
+ return response;
159
+ const parsed = MODELS.safeParse(response.value);
160
+ if (!parsed.success) {
161
+ return { ok: false, error: `unexpected /models response: ${z.prettifyError(parsed.error)}` };
162
+ }
163
+ return { ok: true, value: parsed.data.data.map((model) => model.id) };
164
+ }
@@ -0,0 +1,127 @@
1
+ import { tryExecute } from "../lib/result.js";
2
+ let mupdfPromise = null;
3
+ // Lazy: WASM module isn't loaded until first call.
4
+ function getMupdf() {
5
+ if (!mupdfPromise)
6
+ mupdfPromise = import("mupdf");
7
+ return mupdfPromise;
8
+ }
9
+ const PDF_MIME = "application/pdf";
10
+ // mupdf's authenticatePassword returns 0 on a wrong password, non-zero on success.
11
+ const MUPDF_AUTH_FAILED = 0;
12
+ export async function isEncryptedPdf(bytes) {
13
+ const mupdf = await getMupdf();
14
+ const doc = mupdf.Document.openDocument(bytes, PDF_MIME);
15
+ try {
16
+ return doc.needsPassword();
17
+ }
18
+ finally {
19
+ doc.destroy();
20
+ }
21
+ }
22
+ export async function unlockPdf(bytes, password) {
23
+ const mupdf = await getMupdf();
24
+ const doc = mupdf.Document.openDocument(bytes, PDF_MIME);
25
+ try {
26
+ if (!(doc instanceof mupdf.PDFDocument)) {
27
+ return { ok: false, reason: "unsupported_document" };
28
+ }
29
+ if (!doc.needsPassword()) {
30
+ return { ok: true, decrypted: bytes };
31
+ }
32
+ if (doc.authenticatePassword(password) === MUPDF_AUTH_FAILED) {
33
+ return { ok: false, reason: "wrong_password" };
34
+ }
35
+ const out = doc.saveToBuffer("decrypt");
36
+ return { ok: true, decrypted: Buffer.from(out.asUint8Array()) };
37
+ }
38
+ finally {
39
+ doc.destroy();
40
+ }
41
+ }
42
+ /**
43
+ * `preserve-images` is what makes image blocks visible to the walker; without
44
+ * it a scanned page is indistinguishable from a blank one (verified against
45
+ * mupdf 1.27).
46
+ */
47
+ const STEXT_FLAGS = "preserve-whitespace,preserve-images";
48
+ function hasImageBlock(stext) {
49
+ let found = false;
50
+ stext.walk({
51
+ onImageBlock: () => {
52
+ found = true;
53
+ },
54
+ });
55
+ return found;
56
+ }
57
+ function probeOne(doc, index) {
58
+ const page = doc.loadPage(index);
59
+ try {
60
+ const stext = page.toStructuredText(STEXT_FLAGS);
61
+ try {
62
+ const text = stext.asText().replace(/\n{3,}/g, "\n\n").trim();
63
+ return { page: index + 1, chars: text.length, hasImage: hasImageBlock(stext), text };
64
+ }
65
+ finally {
66
+ stext.destroy();
67
+ }
68
+ }
69
+ finally {
70
+ page.destroy();
71
+ }
72
+ }
73
+ /**
74
+ * Measures and extracts the text layer in the same pass — the text-layer route
75
+ * has nothing left to do afterwards, so no page is ever opened twice.
76
+ */
77
+ export async function probePdfPages(bytes) {
78
+ const mupdf = await getMupdf();
79
+ return tryExecute(() => {
80
+ const doc = mupdf.Document.openDocument(bytes, PDF_MIME);
81
+ try {
82
+ const pages = [];
83
+ for (let index = 0; index < doc.countPages(); index++) {
84
+ pages.push(probeOne(doc, index));
85
+ }
86
+ return pages;
87
+ }
88
+ finally {
89
+ doc.destroy();
90
+ }
91
+ });
92
+ }
93
+ function renderOne(mupdf, doc, index, spec) {
94
+ const page = doc.loadPage(index);
95
+ try {
96
+ const [x0, y0, x1, y1] = page.getBounds();
97
+ const longestPt = Math.max(x1 - x0, y1 - y0);
98
+ const scale = Math.min(spec.dpi / 72, spec.maxLongestDimPx / longestPt);
99
+ // Statements assume paper; alpha:false renders on white.
100
+ const pixmap = page.toPixmap(mupdf.Matrix.scale(scale, scale), mupdf.ColorSpace.DeviceRGB, false);
101
+ try {
102
+ return { page: index + 1, mime: "image/png", bytes: Buffer.from(pixmap.asPNG()) };
103
+ }
104
+ finally {
105
+ pixmap.destroy();
106
+ }
107
+ }
108
+ finally {
109
+ page.destroy();
110
+ }
111
+ }
112
+ export async function renderPdfPages(bytes, spec) {
113
+ const mupdf = await getMupdf();
114
+ return tryExecute(() => {
115
+ const doc = mupdf.Document.openDocument(bytes, PDF_MIME);
116
+ try {
117
+ const pages = [];
118
+ for (let index = 0; index < doc.countPages(); index++) {
119
+ pages.push(renderOne(mupdf, doc, index, spec));
120
+ }
121
+ return pages;
122
+ }
123
+ finally {
124
+ doc.destroy();
125
+ }
126
+ });
127
+ }
@@ -0,0 +1,18 @@
1
+ import { lightonOcrPreset } from "./lighton-ocr.js";
2
+ import { typhoonOcrPreset } from "./typhoon-ocr.js";
3
+ /** Exhaustive by construction: a new PresetName breaks the build until it is listed. */
4
+ export const PRESETS = {
5
+ "typhoon-ocr": typhoonOcrPreset,
6
+ "lighton-ocr": lightonOcrPreset,
7
+ };
8
+ export const PRESET_NAMES = Object.keys(PRESETS);
9
+ /** Its prompt is a generic extract-to-markdown instruction, so it suits an unrecognized model too. */
10
+ const HOUSE_PRESET = "typhoon-ocr";
11
+ /**
12
+ * An unrecognized id — or none at all — gets the house preset, whose own model
13
+ * is then the one to ask for.
14
+ */
15
+ export function presetForModel(modelId) {
16
+ const name = PRESET_NAMES.find((key) => PRESETS[key].family.test(modelId)) ?? HOUSE_PRESET;
17
+ return { name, preset: PRESETS[name] };
18
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * LightOnOCR-2-1B: trained on 11 languages, Thai not among them. Answers in plain
3
+ * markdown (no wrapper to unwrap); its card prescribes 200 DPI, longest side 1540px.
4
+ */
5
+ export const lightonOcrPreset = {
6
+ family: /lighton/i,
7
+ model: "lightonocr-2-1b",
8
+ prompt: "Convert this page to markdown. Preserve all text, numbers, and tables exactly as they appear.",
9
+ // The card prescribes greedy decoding, which leaves top_p at the API default and needs no seed.
10
+ params: { temperature: 0, top_p: 1, max_tokens: 4096 },
11
+ render: { dpi: 200, maxLongestDimPx: 1540 },
12
+ };
@@ -0,0 +1,23 @@
1
+ // The prompt is the model card's, kept per-preset so a card revision only touches this file.
2
+ const PROMPT = `Extract all text from the image.
3
+
4
+ Instructions:
5
+ - Only return the clean Markdown.
6
+ - Do not include any explanation or extra text.
7
+ - You must include all information on the page.
8
+
9
+ Formatting Rules:
10
+ - Tables: Render tables using <table>...</table> in clean HTML format.
11
+ - Equations: Render equations using LaTeX syntax with inline ($...$) and block ($$...$$).
12
+ - Images/Charts/Diagrams: Wrap any clearly defined visual areas in: <figure>Describe...</figure>
13
+ - Page Numbers: Wrap page numbers in <page_number>...</page_number>
14
+ - Checkboxes: Use ☐ for unchecked and ☑ for checked boxes.`;
15
+ export const typhoonOcrPreset = {
16
+ // Every release so far carries the family name, whatever the version suffix.
17
+ family: /typhoon/i,
18
+ model: "typhoon-ocr1.5",
19
+ prompt: PROMPT,
20
+ params: { temperature: 0.1, top_p: 0.6, max_tokens: 4096, seed: 42 },
21
+ // The card trains images at 1800 px on the longest side.
22
+ render: { dpi: 200, maxLongestDimPx: 1800 },
23
+ };
@@ -0,0 +1,40 @@
1
+ /** The whole routing policy.
2
+ * ocr ready ocr unset
3
+ * complete text-layer text-layer
4
+ * partial ocr agent
5
+ * none ocr agent
6
+ */
7
+ const READER = {
8
+ "complete/ready": "text-layer",
9
+ "complete/unset": "text-layer",
10
+ "partial/ready": "ocr",
11
+ "partial/unset": "agent",
12
+ "none/ready": "ocr",
13
+ "none/unset": "agent",
14
+ };
15
+ /**
16
+ * File kind is deliberately not an axis: an image enters as `"none"` and routes
17
+ * like a scan. Availability means configured, not reachable — a dead endpoint
18
+ * fails the run loudly rather than degrading to images behind the caller's back.
19
+ */
20
+ export function readerFor(textLayer, ocr) {
21
+ return READER[`${textLayer}/${ocr}`];
22
+ }
23
+ // Biased toward waste over loss: a scanned page with a junk text layer under this
24
+ // bar still counts as a scan, so it gets re-read instead of silently contributing nothing.
25
+ const PAGE_TEXT_CHARS = 400;
26
+ export function classifyPage(page) {
27
+ if (page.chars >= PAGE_TEXT_CHARS)
28
+ return "text";
29
+ return page.hasImage ? "scan" : "blank";
30
+ }
31
+ /**
32
+ * Blank trailer pages don't spoil "complete"; one scanned page among text pages makes
33
+ * it "partial", re-reading the whole document so every source_page citation matches.
34
+ */
35
+ export function verdictOf(pages) {
36
+ const contents = pages.map(classifyPage);
37
+ if (!contents.includes("text"))
38
+ return "none";
39
+ return contents.includes("scan") ? "partial" : "complete";
40
+ }
@@ -0,0 +1,84 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { basename, extname } from "node:path";
4
+ import { tryExecute } from "../lib/result.js";
5
+ /** `mime` is what `files.mime` records. */
6
+ export const SOURCES = {
7
+ ".pdf": { kind: "pdf", mime: "application/pdf" },
8
+ ".png": { kind: "image", mime: "image/png" },
9
+ ".jpg": { kind: "image", mime: "image/jpeg" },
10
+ ".jpeg": { kind: "image", mime: "image/jpeg" },
11
+ ".webp": { kind: "image", mime: "image/webp" },
12
+ };
13
+ export const SUPPORTED_EXTS = Object.keys(SOURCES);
14
+ export const MAX_SOURCE_BYTES = 30 * 1024 * 1024;
15
+ // Some producers emit a preamble before the header, so the marker is searched, not at offset 0.
16
+ const PDF_SNIFF_BYTES = 1024;
17
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
18
+ const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
19
+ const SIGNATURES = [
20
+ {
21
+ kind: "pdf",
22
+ mime: "application/pdf",
23
+ matches: (bytes) => bytes.subarray(0, PDF_SNIFF_BYTES).toString("latin1").includes("%PDF-"),
24
+ },
25
+ { kind: "image", mime: "image/png", matches: (bytes) => bytes.subarray(0, 8).equals(PNG_MAGIC) },
26
+ { kind: "image", mime: "image/jpeg", matches: (bytes) => bytes.subarray(0, 3).equals(JPEG_MAGIC) },
27
+ {
28
+ kind: "image",
29
+ mime: "image/webp",
30
+ matches: (bytes) => bytes.subarray(0, 4).toString("latin1") === "RIFF" &&
31
+ bytes.subarray(8, 12).toString("latin1") === "WEBP",
32
+ },
33
+ ];
34
+ export function sniffSource(bytes) {
35
+ const hit = SIGNATURES.find((signature) => signature.matches(bytes));
36
+ return hit ? { kind: hit.kind, mime: hit.mime } : null;
37
+ }
38
+ /** The extension declares the type and the magic bytes must agree — a mislabeled
39
+ * file fails here instead of deep in mupdf or the OCR endpoint. */
40
+ export function loadSource(path) {
41
+ const ext = extname(path).toLowerCase();
42
+ const declared = SOURCES[ext];
43
+ if (!declared) {
44
+ return {
45
+ ok: false,
46
+ reason: "unsupported_extension",
47
+ message: `unsupported extension ${ext || basename(path)} (accepted: ${SUPPORTED_EXTS.join(" ")})`,
48
+ };
49
+ }
50
+ // Size is checked before the read so an oversized file is never pulled into memory.
51
+ const stat = tryExecute(() => statSync(path));
52
+ if (!stat.ok)
53
+ return { ok: false, reason: "unreadable", message: stat.error };
54
+ if (stat.value.size > MAX_SOURCE_BYTES) {
55
+ return {
56
+ ok: false,
57
+ reason: "too_large",
58
+ message: `${stat.value.size} bytes exceeds the ${MAX_SOURCE_BYTES}-byte limit`,
59
+ };
60
+ }
61
+ const read = tryExecute(() => readFileSync(path));
62
+ if (!read.ok)
63
+ return { ok: false, reason: "unreadable", message: read.error };
64
+ const bytes = read.value;
65
+ const sniffed = sniffSource(bytes);
66
+ if (!sniffed || sniffed.mime !== declared.mime) {
67
+ return {
68
+ ok: false,
69
+ reason: "kind_mismatch",
70
+ message: `${ext} file holds ${sniffed?.mime ?? "unrecognized"} bytes`,
71
+ };
72
+ }
73
+ return {
74
+ ok: true,
75
+ value: {
76
+ path,
77
+ fileName: basename(path),
78
+ kind: declared.kind,
79
+ mime: declared.mime,
80
+ bytes,
81
+ hash: createHash("sha256").update(bytes).digest("hex"),
82
+ },
83
+ };
84
+ }