@rekhu/core 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@rekhu/core`. Versions follow [semantic versioning](https://semver.org).
4
+
5
+ ## 0.2.0 · 6 November 2026
6
+
7
+ - **Seshat in modules:** `seshat().modules()` says which modules this application may use (`preflight`, `ai_enhance`); a call to a module that is off is refused with `module_not_enabled`.
8
+ - **ai_enhance:** `seshat().enhance(picture, modes, options, extra)` improves one picture without a check:
9
+ - the modes are upscale to a print size, extend, denoise, sharpen, restore, remove_background, replace_background, remove_object, vectorize and colour_correct;
10
+ - large pictures run as a job: `poll(jobId)`, or a webhook checked with `verifyWebhook()`;
11
+ - `enhancePreview(fileId)` gives a before / after picture, and `upload()` keeps a mask or background picture.
12
+
13
+ ## 0.1.0 · 6 November 2026
14
+
15
+ - **Rekhu Core for Node.js.** Rekhu's knowers in a Node.js back end, with the app's own tenants (`tenant`), languages (`locale`), limits a day per tenant and user (`limits`, `limitsFor`), its own price per call (`price`) and every call kept in a ledger (`memoryLedger`, `fileLedger`, or the app's own) and passed to `onUsage`.
16
+ - **Seshat**: check a print file against its order, fix it, preview a page, download the fixed file.
17
+ - **Rekhu Machines**: identify, profile, check, choose, explain; feedback on an answer.
18
+ - `npx rekhu-core usage` for a file ledger, per tenant and month, with CSV. Cloud or private deployment.
package/LICENSE.txt ADDED
@@ -0,0 +1,18 @@
1
+ Rekhu Core for Node.js (@rekhu/core), by Rekhu (Charisma Design, the Netherlands)
2
+
3
+ This software is licensed, not sold. It is provided to holders of a Rekhu account under the
4
+ Rekhu Terms of Service at https://rekhu.com/terms, which grant a limited, revocable,
5
+ non-exclusive, non-transferable licence to install and run the package in applications
6
+ linked to that account by a valid site key, solely to use the Rekhu services under the account's plan.
7
+
8
+ The package is a client of the Rekhu platform: it functions only with a valid site key issued by the
9
+ platform, verifies that key with the platform, and performs work only on instructions received through
10
+ the platform. A revoked key stops the package in that application.
11
+
12
+ You may not copy, modify, distribute, sublicense, host for third parties, reverse engineer, or use the
13
+ package with any server, service, model or backend other than the Rekhu platform, nor remove or
14
+ circumvent the key check, licence verification, metering or security measures, except where mandatory
15
+ law expressly allows. Code, migrations and tests that the agent writes in your application are your own,
16
+ as the Terms describe.
17
+
18
+ All rights reserved. hello@rekhu.com
package/README.md CHANGED
@@ -1,3 +1,139 @@
1
- # Temporary Holding Version
1
+ # Rekhu Core for Node.js
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Rekhu's **knowers** in your Node.js back end. A knower is a specialist that knows one part of a trade the way its best expert does: it understands what is asked, decides, explains in your user's language and learns from what your users accept or correct. The first two know print: **Seshat** knows print files, **Rekhu Machines** knows printing and finishing machines.
4
+
5
+ Your tenants, users, limits and prices stay yours: every call is kept in your ledger, next to what Rekhu charged you, so you can charge your customers per call or per plan. Express, Next.js, Nuxt, Hono, Bun and Deno.
6
+
7
+ ```bash
8
+ npm install @rekhu/core
9
+ ```
10
+
11
+ ```js
12
+ import { createRekhu } from '@rekhu/core';
13
+
14
+ const rekhu = createRekhu({
15
+ key: process.env.REKHU_APP_KEY, // the app key from your Rekhu account
16
+ knowers: ['seshat', 'machines'],
17
+ tenant: (user) => ({ id: user.teamId, name: user.teamName }), // multi-tenant: the customer a user belongs to
18
+ locales: ['en', 'nl', 'ar'],
19
+ locale: (user) => user.language,
20
+ limits: { seshat: { perDayPerTenant: 500 } },
21
+ price: (tenant, knower, action, meta) => 0.02, // what you charge your customer for a call
22
+ onUsage: (row) => db.rekhuUsage.insert(row), // or ledger: { record, count } in your own database
23
+ });
24
+ ```
25
+
26
+ No Rekhu account yet? [Create one](https://rekhu.com/register?ref=core-node): it starts with $10 credit, no card needed.
27
+
28
+ ## Seshat: print files
29
+
30
+ Seshat checks a print file **against the order it is for**. Each finding comes with Seshat's decision: `fix`, `ask` (the customer chooses), `refuse` or `note`.
31
+
32
+ ```js
33
+ const seshat = rekhu.seshat({ user });
34
+ const order = { width_mm: 148, height_mm: 210, bleed_mm: 3, pages: 2, colour: 'cmyk' };
35
+
36
+ const check = await seshat.check(fileBuffer, { name: 'flyer.pdf', order, explain: true });
37
+ const fixed = await seshat.fix(check.file_id, 'auto', order); // or [{ fix: 'scale_to_size', option: 'fit' }, { fix: 'add_bleed' }]
38
+ const pdf = await seshat.download(fixed.file_id); // a Buffer
39
+ const proof = await seshat.preview(fixed.file_id); // { image: 'data:image/png;base64,…' }
40
+ ```
41
+
42
+ It checks:
43
+ - size against the order, and the trim and bleed;
44
+ - the resolution of every picture as placed;
45
+ - RGB and spot colours, fonts and total ink;
46
+ - the pages, small text and thin lines;
47
+ - white set to overprint, and text near the cut;
48
+ - pictures, EPS files and zips.
49
+
50
+ It fixes:
51
+ - the bleed, by mirroring the edges;
52
+ - the size: scale, fit or fill;
53
+ - the trim box;
54
+ - CMYK colours and embedded fonts;
55
+ - pictures, made into print PDFs.
56
+
57
+ ## Seshat AI: one picture improved (ai_enhance)
58
+
59
+ For a platform with its own preflight that only wants Seshat's image work, or next to the preflight. The module is switched on per application by Rekhu; `modules()` tells which ones this key may use.
60
+
61
+ | Mode | What it does | Options |
62
+ |---|---|---|
63
+ | `upscale` | More pixels, up to 4×: to a resolution at a print size | `ppi`, `width_mm`, `height_mm`, or `factor` |
64
+ | `extend` | Grows the picture beyond its edges (bleed); a little per side on CPU | `all_mm`, or `top_mm` `right_mm` `bottom_mm` `left_mm` (or `_px`) |
65
+ | `denoise`, `sharpen` | Cleans JPEG artefacts and noise; crisper edges | `strength`, `amount` |
66
+ | `restore` | An old or scanned photo: scratches filled, noise and faded colour corrected | |
67
+ | `remove_background` | Transparent PNG, or a solid colour | `background: '#ffffff'` |
68
+ | `replace_background` | A new background from a picture you send | `background_file_id` (from `upload`) |
69
+ | `remove_object` | Fills an object or blemish from its surroundings | `region: {x, y, w, h}`, or `mask_file_id` |
70
+ | `vectorize` | A logo or line drawing as SVG. A photo is refused (`not_vectorizable`) | |
71
+ | `colour_correct` | Exposure and white balance | |
72
+
73
+ Every answer says what it invented in `warnings` (an edge, a filled area, a reconstructed detail), with a `confidence` from 0 to 1. RGB stays RGB, and CMYK stays CMYK unless the mode needs otherwise. Files are kept a day and never used for training. A large picture runs as a job: poll it, or give a webhook URL. The webhook is signed `x-rekhu-signature: t=…,v1=…` with the `webhook_secret` the call answered. Each mode is priced on its own, and `meta.usage` says per item what it cost.
74
+
75
+ Limits:
76
+ - at most 40 megapixels in and 120 megapixels out;
77
+ - upscaling at most 4×;
78
+ - extending at most 12 % of a side on CPU;
79
+ - removing at most a quarter of the picture.
80
+
81
+ ```js
82
+ const s = rekhu.seshat({ tenant: { id: tenant.uuid, name: tenant.name } });
83
+ await s.modules(); // { modules: { preflight: false, ai_enhance: true }, modes: [...] }
84
+ const r = await s.enhance(photoBuffer, ['denoise', 'upscale'], { ppi: 300, width_mm: 100, height_mm: 70 }, { name: 'photo.png', webhookUrl: 'https://shop.example.com/hooks/rekhu' });
85
+ const { image } = await s.enhancePreview(r.file_id); // before | after, a data: URL
86
+ const png = await s.download(r.file_id); // a Buffer
87
+ await s.poll('j_…'); // for a job
88
+
89
+ import { verifyWebhook } from '@rekhu/core';
90
+ if (!verifyWebhook(rawBody, req.headers['x-rekhu-signature'], storedSecret)) return res.status(401).end();
91
+ ```
92
+
93
+ ## Rekhu Machines
94
+
95
+ ```js
96
+ const machines = rekhu.machines({ user });
97
+ await machines.identify('Heidelberg Speedmaster SM 74-4 with coater, 2009');
98
+ await machines.profile('heidelberg-sm-74', { colour_units: 4 });
99
+ await machines.check(machineProfile, { model: 'heidelberg-sm-74', explain: true });
100
+ await machines.choose({ width_mm: 85, height_mm: 55, quantity: 5000, colours_front: 4, colours_back: 4, gsm: 350 }, shopMachines);
101
+ await machines.explain('Can I print 400 g/m² board on it?', { model: 'heidelberg-sm-74' });
102
+ ```
103
+
104
+ The machine profile:
105
+
106
+ | Field | |
107
+ |---|---|
108
+ | `type` | printing, cutting, … |
109
+ | `method` | offset, digital_toner, digital_liquid, inkjet_sheet, large_format, guillotine |
110
+ | `sheet_max_mm`, `sheet_min_mm` | `[w, h]` |
111
+ | `gsm` | `[min, max]` |
112
+ | `colour_units`, `perfecting` | |
113
+ | `speed`, `speed_unit` | |
114
+ | `makeready_minutes`, `makeready_waste_sheets` | |
115
+ | `gripper_mm` | |
116
+
117
+ Every answer has a `ref`; `machines.feedback(ref, 'corrected', { speed: 12000 })` is what the knower learns from.
118
+
119
+ ## Usage per tenant
120
+
121
+ The default ledger is in memory. For a single server, `fileLedger('storage/rekhu-usage.jsonl')` keeps it in a file:
122
+
123
+ ```bash
124
+ npx rekhu-core usage --ledger storage/rekhu-usage.jsonl --month 2026-11 --csv usage-2026-11.csv
125
+ ```
126
+
127
+ Calls, model calls, what Rekhu charged and your own price, per tenant, knower and action. With several servers, give `ledger: { record(row), count(filter) }` backed by your own database.
128
+
129
+ ## Where Rekhu runs
130
+
131
+ - `REKHU_DEPLOYMENT=cloud` (the default) uses Rekhu's platform.
132
+ - `private` uses Rekhu inside your network: set `REKHU_PLATFORM` to its address.
133
+
134
+ The knowers and this package are the same in both.
135
+
136
+ ## Errors
137
+
138
+ - `RekhuError` carries `status` and `type`.
139
+ - `LimitReached` (429) carries `scope: 'tenant' | 'user'`; nothing is sent to Rekhu.
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ // npx rekhu-core usage --ledger storage/rekhu-usage.jsonl [--month 2026-11] [--tenant shop-42] [--csv usage.csv]
3
+ // The usage of Rekhu's knowers per tenant in a month, from a file ledger (fileLedger).
4
+
5
+ import { writeFile } from 'node:fs/promises';
6
+ import { fileLedger, usage } from '../src/ledger.js';
7
+
8
+ const args = process.argv.slice(2);
9
+ const option = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; };
10
+ if (args[0] !== 'usage' || !option('ledger')) {
11
+ console.log('npx rekhu-core usage --ledger <file> [--month YYYY-MM] [--tenant <id>] [--csv <file>]');
12
+ process.exit(args[0] ? 1 : 0);
13
+ }
14
+ try {
15
+ const rows = await usage(fileLedger(option('ledger')), { month: option('month'), tenant: option('tenant') });
16
+ if (!rows.length) { console.log('No usage in that month.'); process.exit(0); }
17
+ console.table(rows.map((r) => ({ tenant: r.tenant, name: r.name, knower: r.knower, action: r.action, calls: r.calls, 'model calls': r.model_calls, 'Rekhu cost (USD)': r.cost_usd.toFixed(4), 'your price': r.price === null ? '' : r.price.toFixed(2) })));
18
+ if (option('csv')) {
19
+ const quote = (v) => (/[",\n]/.test(String(v)) ? `"${String(v).replace(/"/g, '""')}"` : String(v ?? ''));
20
+ const lines = [['tenant', 'name', 'knower', 'action', 'calls', 'model calls', 'Rekhu cost (USD)', 'your price'], ...rows.map((r) => [r.tenant, r.name, r.knower, r.action, r.calls, r.model_calls, r.cost_usd.toFixed(4), r.price === null ? '' : r.price.toFixed(2)])];
21
+ await writeFile(option('csv'), lines.map((l) => l.map(quote).join(',')).join('\n') + '\n');
22
+ console.log(`Written to ${option('csv')}.`);
23
+ }
24
+ } catch (error) {
25
+ console.error(error.message);
26
+ process.exit(1);
27
+ }
package/index.d.ts ADDED
@@ -0,0 +1,73 @@
1
+ export const VERSION: string;
2
+
3
+ export interface Tenant { id: string; name: string; [key: string]: unknown }
4
+ export interface UsageRow {
5
+ tenant_id: string | null; tenant_name: string | null; user_id: string | null; knower: string; action: string; ref: string | null;
6
+ ok: boolean; model_calls: number; cost_usd: number; price: number | null; ms: number; created_at: string;
7
+ }
8
+ export interface Ledger {
9
+ record(row: UsageRow): Promise<void> | void;
10
+ count(filter: { knower?: string; tenant?: string; user?: string; since?: string; until?: string }): Promise<number> | number;
11
+ rows?(filter?: { knower?: string; tenant?: string; user?: string; since?: string; until?: string }): Promise<UsageRow[]>;
12
+ }
13
+ export interface Limits { perDayPerTenant?: number | null; perDayPerUser?: number | null }
14
+
15
+ export interface RekhuOptions<User = any> {
16
+ key?: string;
17
+ platform?: string;
18
+ deployment?: 'cloud' | 'private';
19
+ tenancy?: 'single' | 'multi';
20
+ appId?: string;
21
+ appName?: string;
22
+ knowers?: string[];
23
+ locales?: string[];
24
+ tenant?: (user: User) => Tenant | string | null | Promise<Tenant | string | null>;
25
+ userId?: (user: User) => string | number;
26
+ locale?: (user: User) => string | null | Promise<string | null>;
27
+ limits?: Record<string, Limits>;
28
+ limitsFor?: (tenant: Tenant | null, knower: string) => Limits | Promise<Limits>;
29
+ price?: (tenant: Tenant | null, knower: string, action: string, meta: Record<string, unknown>) => number | null | Promise<number | null>;
30
+ onUsage?: (row: UsageRow & { tenant: Tenant | null; user: User | null }) => void | Promise<void>;
31
+ ledger?: Ledger | false;
32
+ timeout?: number;
33
+ fetch?: typeof fetch;
34
+ }
35
+
36
+ export interface Who<User = any> { user?: User | null; userId?: string | number; tenant?: { id: string; name?: string } }
37
+ export type Answer = Record<string, any> & { ref?: string; meta?: { model_calls: number; cost_usd: number; ms: number } };
38
+
39
+ export interface Machines {
40
+ models(): Promise<Array<{ id: string; maker: string; model: string; type: string; method: string; format: string | null }>>;
41
+ identify(text: string): Promise<Answer>;
42
+ profile(model: string, known?: Record<string, unknown>): Promise<Answer>;
43
+ check(machine: Record<string, unknown>, options?: { model?: string; explain?: boolean }): Promise<Answer>;
44
+ choose(job: { width_mm: number; height_mm: number; quantity: number; bleed_mm?: number; colours_front?: number; colours_back?: number; gsm?: number }, machines: Array<Record<string, unknown>>): Promise<Answer>;
45
+ explain(question: string, options?: { machine?: Record<string, unknown>; model?: string }): Promise<Answer>;
46
+ feedback(ref: string, outcome: 'accepted' | 'corrected' | 'rejected', correction?: Record<string, unknown>): Promise<Answer>;
47
+ }
48
+
49
+ export interface Order { width_mm?: number; height_mm?: number; bleed_mm?: number; pages?: number; colour?: 'cmyk' | 'any'; min_ppi?: number; safe_mm?: number; max_ink?: number; spot_colours_allowed?: boolean }
50
+ export interface Seshat {
51
+ check(file: Uint8Array | ArrayBuffer | Blob, options: { name: string; order?: Order; explain?: boolean }): Promise<Answer>;
52
+ fix(fileId: string, fixes?: 'auto' | Array<{ fix: string; option?: string }>, order?: Order): Promise<Answer>;
53
+ preview(fileId: string, options?: { page?: number; longest?: number }): Promise<Answer>;
54
+ download(fileId: string): Promise<Buffer>;
55
+ feedback(ref: string, outcome: 'accepted' | 'corrected' | 'rejected', correction?: Record<string, unknown>): Promise<Answer>;
56
+ }
57
+
58
+ export interface Rekhu<User = any> {
59
+ config: Record<string, unknown>;
60
+ machines(who?: Who<User>): Machines;
61
+ seshat(who?: Who<User>): Seshat;
62
+ knower(name: string): (who?: Who<User>) => { call(action: string, input?: Record<string, unknown>): Promise<Answer>; feedback(ref: string, outcome: string, correction?: Record<string, unknown>): Promise<Answer> };
63
+ installed(name: string): boolean;
64
+ usage(filter?: { month?: string; tenant?: string }): Promise<Array<{ tenant: string; name: string; knower: string; action: string; calls: number; model_calls: number; cost_usd: number; price: number | null }>>;
65
+ }
66
+
67
+ export function createRekhu<User = any>(options?: RekhuOptions<User>): Rekhu<User>;
68
+ export function memoryLedger(): Ledger;
69
+ export function fileLedger(path: string): Ledger;
70
+ export function usage(ledger: Ledger, filter?: { month?: string; tenant?: string }): Promise<unknown[]>;
71
+ export function parseKey(key: string): { key: string; customer: string; agent: string } | null;
72
+ export class RekhuError extends Error { status: number; type: string }
73
+ export class LimitReached extends RekhuError { scope: 'tenant' | 'user' }
package/package.json CHANGED
@@ -1,6 +1,17 @@
1
1
  {
2
2
  "name": "@rekhu/core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Rekhu Core for Node.js: Rekhu's knowers in your app (Seshat for print files, Rekhu Machines for printing machines), with your own tenants, languages, limits and a usage ledger per customer.",
5
+ "keywords": ["ai", "print", "printing", "preflight", "pdf", "machines", "multi-tenant", "rekhu"],
6
+ "homepage": "https://rekhu.com",
7
+ "license": "SEE LICENSE IN LICENSE.txt",
8
+ "author": "Rekhu <hello@rekhu.com> (https://rekhu.com)",
9
+ "type": "module",
10
+ "main": "./src/index.js",
11
+ "types": "./index.d.ts",
12
+ "bin": { "rekhu-core": "bin/rekhu-core.js" },
13
+ "exports": { ".": { "types": "./index.d.ts", "import": "./src/index.js" } },
14
+ "engines": { "node": ">=20" },
15
+ "files": ["src", "bin", "index.d.ts", "README.md", "CHANGELOG.md", "LICENSE.txt"],
16
+ "scripts": { "test": "node --test test/*.test.js" }
17
+ }
package/src/index.js ADDED
@@ -0,0 +1,221 @@
1
+ // Rekhu Core for Node.js: Rekhu's knowers in the app's own back end. A knower knows one part of a trade the way its
2
+ // best expert does; Seshat knows print files, Rekhu Machines knows printing and finishing machines. The app keeps its
3
+ // tenants, languages, limits and prices: every call is kept in its ledger, with what Rekhu charged and its own price.
4
+ //
5
+ // const rekhu = createRekhu({ tenant: (user) => ({ id: user.teamId, name: user.teamName }), knowers: ['seshat', 'machines'] });
6
+ // await rekhu.machines({ user }).identify('Heidelberg Speedmaster SM 74-4 with coater');
7
+ // await rekhu.seshat({ user }).check(pdfBuffer, { name: 'flyer.pdf', order: { width_mm: 148, height_mm: 210 } });
8
+
9
+ import { createHmac, timingSafeEqual } from 'node:crypto';
10
+ import { memoryLedger, fileLedger, usage } from './ledger.js';
11
+
12
+ export { memoryLedger, fileLedger, usage };
13
+ export const VERSION = '0.2.0';
14
+
15
+ /**
16
+ * Whether a webhook Rekhu sent is genuine: the x-rekhu-signature header (t=…,v1=…) over the raw body, with the
17
+ * webhook_secret the call answered, and not older than `tolerance` seconds.
18
+ */
19
+ export function verifyWebhook(body, signature, secret, tolerance = 300) {
20
+ const m = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(String(signature || ''));
21
+ if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > tolerance) return false;
22
+ const expected = createHmac('sha256', secret).update(`${m[1]}.${body}`).digest();
23
+ const given = Buffer.from(m[2], 'hex');
24
+ return given.length === expected.length && timingSafeEqual(given, expected);
25
+ }
26
+
27
+ /** A knower could not answer: Rekhu's status (0 when the call was not made) and its error type. */
28
+ export class RekhuError extends Error {
29
+ constructor(message, status = 0, type = 'api_error') {
30
+ super(message);
31
+ this.name = 'RekhuError';
32
+ this.status = status;
33
+ this.type = type;
34
+ }
35
+ }
36
+
37
+ /** One of the app's own limits a day is reached (scope: tenant or user); nothing was sent to Rekhu. */
38
+ export class LimitReached extends RekhuError {
39
+ constructor(message, scope) {
40
+ super(message, 429, 'limit_reached');
41
+ this.name = 'LimitReached';
42
+ this.scope = scope;
43
+ }
44
+ }
45
+
46
+ /** The key's parts: agk_<customer>_<agent>_<secret>, or null when it is not a Rekhu app key. */
47
+ export function parseKey(key) {
48
+ const m = /^agk_([a-z0-9-]+)_([a-z0-9-]+)_[A-Za-z0-9_-]+$/.exec(String(key || ''));
49
+ return m ? { key: m[0], customer: m[1], agent: m[2] } : null;
50
+ }
51
+
52
+ export function createRekhu(options = {}) {
53
+ const env = (name) => (typeof process !== 'undefined' ? process.env[name] : undefined);
54
+ const config = {
55
+ key: options.key ?? env('REKHU_APP_KEY') ?? env('REKHU_SECRET_KEY'),
56
+ deployment: options.deployment || env('REKHU_DEPLOYMENT') || 'cloud',
57
+ platform: String(options.platform || env('REKHU_PLATFORM') || 'https://api.rekhu.ai').replace(/\/$/, ''),
58
+ tenancy: options.tenancy || (options.tenant ? 'multi' : 'single'),
59
+ appId: options.appId || 'app',
60
+ appName: options.appName || 'the application',
61
+ locales: (options.locales || String(env('REKHU_LOCALES') || 'en').split(',')).map((l) => String(l).trim().toLowerCase()).filter(Boolean),
62
+ knowers: options.knowers || String(env('REKHU_KNOWERS') || '').split(',').map((k) => k.trim()).filter(Boolean),
63
+ limits: options.limits || {},
64
+ ledger: options.ledger === false ? null : options.ledger || memoryLedger(),
65
+ timeout: options.timeout || 180000,
66
+ fetch: options.fetch || globalThis.fetch,
67
+ };
68
+ const resolvers = { tenant: options.tenant || null, locale: options.locale || null, limits: options.limitsFor || null, price: options.price || null, onUsage: options.onUsage || null };
69
+
70
+ function platform() {
71
+ if (config.deployment === 'private' && (!config.platform || config.platform === 'https://api.rekhu.ai')) {
72
+ throw new RekhuError('REKHU_DEPLOYMENT is private: set REKHU_PLATFORM to the address of Rekhu inside your network.', 0, 'configuration_error');
73
+ }
74
+ if (!['cloud', 'private'].includes(config.deployment)) throw new RekhuError(`REKHU_DEPLOYMENT ${config.deployment} is not available; use cloud or private.`, 0, 'configuration_error');
75
+ return config.platform;
76
+ }
77
+
78
+ function key() {
79
+ const k = parseKey(config.key);
80
+ if (!k) throw new RekhuError('REKHU_APP_KEY is missing or not a Rekhu app key (agk_…). No Rekhu account yet? Create one at https://rekhu.com/register?ref=core-node: it starts with $10 credit, no card needed.', 0, 'configuration_error');
81
+ return k;
82
+ }
83
+
84
+ async function tenantOf(user, named) {
85
+ if (named) return { id: String(named.id), name: String(named.name ?? named.id) };
86
+ if (config.tenancy !== 'multi') return { id: config.appId, name: config.appName };
87
+ const t = resolvers.tenant && user ? await resolvers.tenant(user) : null;
88
+ if (t && typeof t === 'object' && t.id !== undefined && t.id !== null && t.id !== '') return { id: String(t.id), name: String(t.name ?? t.id), ...t, id: String(t.id) };
89
+ if (t !== null && t !== undefined && t !== '' && typeof t !== 'object') return { id: String(t), name: String(t) };
90
+ return null;
91
+ }
92
+
93
+ async function localeOf(user) {
94
+ const asked = String((resolvers.locale && user ? await resolvers.locale(user) : null) || config.locales[0] || 'en').slice(0, 2).toLowerCase();
95
+ return config.locales.includes(asked) ? asked : config.locales[0] || 'en';
96
+ }
97
+
98
+ async function checkLimits(tenant, userId, knower) {
99
+ if (!config.ledger) return;
100
+ const limits = { ...(config.limits[knower] || {}), ...((resolvers.limits && (await resolvers.limits(tenant, knower))) || {}) };
101
+ const since = new Date(new Date().toISOString().slice(0, 10) + 'T00:00:00.000Z').toISOString();
102
+ if (limits.perDayPerTenant != null && tenant && (await config.ledger.count({ knower, tenant: tenant.id, since })) >= limits.perDayPerTenant) {
103
+ throw new LimitReached(`${tenant.name} has used today's ${limits.perDayPerTenant} calls to ${knower}.`, 'tenant');
104
+ }
105
+ if (limits.perDayPerUser != null && userId != null && (await config.ledger.count({ knower, user: String(userId), since })) >= limits.perDayPerUser) {
106
+ throw new LimitReached(`Today's ${limits.perDayPerUser} calls to ${knower} are used up.`, 'user');
107
+ }
108
+ }
109
+
110
+ async function failed(response) {
111
+ const error = await response.json().catch(() => ({}));
112
+ return new RekhuError(error?.error?.message || `Rekhu answered ${response.status}`, response.status, error?.error?.type || 'api_error');
113
+ }
114
+
115
+ /** One call to a knower for a user (or a named tenant): the app's limits, the call, the ledger and onUsage. */
116
+ async function call(knower, action, input = {}, { user = null, userId = null, tenant: named = null, file = null } = {}) {
117
+ const k = key();
118
+ if (!config.knowers.includes(knower)) throw new RekhuError(`The knower ${knower} is not installed in this app: add it to knowers (or REKHU_KNOWERS).`, 0, 'configuration_error');
119
+ const tenant = await tenantOf(user, named);
120
+ if (config.tenancy === 'multi' && !tenant && !['models', 'modules'].includes(action)) throw new RekhuError('This app is multi-tenant, and no tenant was found for the user: give createRekhu a tenant function, or pass a tenant.', 0, 'configuration_error');
121
+ const who = userId ?? (user ? (options.userId ? options.userId(user) : user.id) : null);
122
+ if (!['models', 'modules', 'upload', 'poll', 'enhance_preview'].includes(action)) await checkLimits(tenant, who, knower);
123
+ const body = ['check', 'explain'].includes(action) && input.language === undefined ? { ...input, language: await localeOf(user) } : input;
124
+ const headers = { accept: 'application/json', 'x-api-key': k.key, 'x-agent': k.agent, 'x-tenant': String(tenant?.id ?? config.appId), 'x-tenant-name': String(tenant?.name ?? config.appName), ...(who != null ? { 'x-user': String(who) } : {}) };
125
+ const started = Date.now();
126
+ let response;
127
+ try {
128
+ response = file
129
+ ? await config.fetch(`${platform()}/v1/knowers/${knower}/${action}`, { method: 'POST', headers: { ...headers, 'content-type': 'application/octet-stream', 'x-file-name': file.name, 'x-knower-input': JSON.stringify(body) }, body: file.bytes, signal: AbortSignal.timeout(config.timeout) })
130
+ : await config.fetch(`${platform()}/v1/knowers/${knower}/${action}`, { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(config.timeout) });
131
+ } catch (error) {
132
+ if (error instanceof RekhuError) throw error;
133
+ throw new RekhuError('Rekhu could not be reached; try again in a moment.', 503, 'connection_error');
134
+ }
135
+ if (!response.ok) throw await failed(response);
136
+ const answer = await response.json();
137
+ // what is not charged is not kept in the ledger
138
+ if (['models', 'modules', 'upload', 'poll', 'enhance_preview'].includes(action)) return answer;
139
+ const meta = answer.meta || {};
140
+ const row = {
141
+ tenant_id: tenant?.id ?? null, tenant_name: tenant?.name ?? null, user_id: who != null ? String(who) : null, knower, action, ref: answer.ref ?? null,
142
+ ok: true, model_calls: Number(meta.model_calls || 0), cost_usd: Number(meta.cost_usd || 0),
143
+ price: resolvers.price ? (await resolvers.price(tenant, knower, action, meta)) ?? null : null, ms: Date.now() - started, created_at: new Date().toISOString(),
144
+ };
145
+ if (config.ledger) await config.ledger.record(row);
146
+ if (resolvers.onUsage) await resolvers.onUsage({ ...row, tenant, user });
147
+ return answer;
148
+ }
149
+
150
+ async function feedback(knower, ref, outcome, correction = null) {
151
+ const k = key();
152
+ const response = await config.fetch(`${platform()}/v1/knowers/${knower}/feedback`, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json', 'x-api-key': k.key, 'x-agent': k.agent }, body: JSON.stringify({ ref, outcome, ...(correction ? { correction } : {}) }) });
153
+ if (!response.ok) throw await failed(response);
154
+ return response.json();
155
+ }
156
+
157
+ async function download(knower, fileId) {
158
+ const k = key();
159
+ const response = await config.fetch(`${platform()}/v1/knowers/${knower}/files/${encodeURIComponent(fileId)}`, { headers: { 'x-api-key': k.key, 'x-agent': k.agent } });
160
+ if (!response.ok) throw await failed(response);
161
+ return Buffer.from(await response.arrayBuffer());
162
+ }
163
+
164
+ const knowerFor = (name) => (who = {}) => ({ call: (action, input, extra = {}) => call(name, action, input, { ...who, ...extra }), feedback: (ref, outcome, correction) => feedback(name, ref, outcome, correction) });
165
+
166
+ /** Rekhu Machines: printing and finishing machines, sent as a Rekhu machine profile (see the README). */
167
+ const machines = (who = {}) => {
168
+ const k = knowerFor('machines')(who);
169
+ return {
170
+ ...k,
171
+ models: async () => (await k.call('models', {})).models || [],
172
+ identify: (text) => k.call('identify', { text }),
173
+ profile: (model, known = {}) => k.call('profile', { model, known }),
174
+ check: (machine, { model = null, explain = false } = {}) => k.call('check', { machine, ...(model ? { model } : {}), ...(explain ? { explain: true } : {}) }),
175
+ choose: (job, list) => k.call('choose', { job, machines: list }),
176
+ explain: (question, { machine = null, model = null } = {}) => k.call('explain', { question, ...(machine ? { machine } : {}), ...(model ? { model } : {}) }),
177
+ };
178
+ };
179
+
180
+ const bytesOf = async (file) => (file instanceof Uint8Array ? file : file instanceof ArrayBuffer ? new Uint8Array(file) : typeof file?.arrayBuffer === 'function' ? new Uint8Array(await file.arrayBuffer()) : null);
181
+
182
+ /** Seshat: a print file against the order it is for; fix, preview, download. With ai_enhance: one picture improved. */
183
+ const seshat = (who = {}) => {
184
+ const k = knowerFor('seshat')(who);
185
+ const file = async (f, name, what) => {
186
+ if (!name) throw new RekhuError(`Give the file's name: ${what}(bytes, { name: 'flyer.pdf' }).`, 0, 'invalid_request_error');
187
+ const bytes = await bytesOf(f);
188
+ if (!bytes) throw new RekhuError('The file is a Buffer, Uint8Array, ArrayBuffer or Blob.', 0, 'invalid_request_error');
189
+ return { name, bytes };
190
+ };
191
+ return {
192
+ ...k,
193
+ check: async (f, { name, order = {}, explain = false } = {}) => k.call('check', { order, ...(explain ? { explain: true } : {}) }, { file: await file(f, name, 'check') }),
194
+ /** Which modules this application may use (preflight, ai_enhance) and the modes. Not charged. */
195
+ modules: () => k.call('modules', {}),
196
+ /** A file kept for a day (a mask, a background picture): its file_id. Not charged. */
197
+ upload: async (f, { name } = {}) => k.call('upload', {}, { file: await file(f, name, 'upload') }),
198
+ /**
199
+ * ai_enhance: one picture (bytes with a name, or a file_id) through the modes, in order. options: ppi, width_mm,
200
+ * height_mm, factor, all_mm…, background, region; extra: async, webhookUrl, maskFileId, backgroundFileId.
201
+ * A large picture answers { status: 'queued', job_id }: poll() it, or verify the webhook with verifyWebhook().
202
+ */
203
+ enhance: async (picture, modes, options = {}, extra = {}) => {
204
+ const input = {
205
+ mode: [].concat(modes), options,
206
+ ...(extra.async ? { async: true } : {}), ...(extra.webhookUrl ? { webhook_url: extra.webhookUrl } : {}),
207
+ ...(extra.maskFileId ? { mask_file_id: extra.maskFileId } : {}), ...(extra.backgroundFileId ? { background_file_id: extra.backgroundFileId } : {}),
208
+ };
209
+ if (typeof picture === 'string' && /^f_[a-z0-9]{24}$/.test(picture)) return k.call('enhance', { ...input, file_id: picture });
210
+ return k.call('enhance', input, { file: await file(picture, extra.name, 'enhance') });
211
+ },
212
+ enhancePreview: (fileId, { longest = 900 } = {}) => k.call('enhance_preview', { file_id: fileId, longest }),
213
+ poll: (jobId) => k.call('poll', { job_id: jobId }),
214
+ fix: (fileId, fixes = 'auto', order = {}) => k.call('fix', { file_id: fileId, fixes, order }),
215
+ preview: (fileId, { page = 1, longest = 1200 } = {}) => k.call('preview', { file_id: fileId, page, longest }),
216
+ download: (fileId) => download('seshat', fileId),
217
+ };
218
+ };
219
+
220
+ return { config, machines, seshat, knower: knowerFor, installed: (name) => config.knowers.includes(name), usage: (filter) => usage(config.ledger, filter) };
221
+ }
package/src/ledger.js ADDED
@@ -0,0 +1,60 @@
1
+ // Where every call to a knower is kept: who (tenant, user), which knower and action, what Rekhu charged and the app's
2
+ // own price. The app's limits a day are counted from it. Any object with record(row) and count(filter) works: a table
3
+ // in the app's own database is the usual choice; these two are for one process, and for a single server.
4
+
5
+ import { appendFile, mkdir, readFile } from 'node:fs/promises';
6
+ import { dirname } from 'node:path';
7
+
8
+ const matches = (row, { knower, tenant, user, since, until }) =>
9
+ (!knower || row.knower === knower)
10
+ && (tenant === undefined || row.tenant_id === tenant)
11
+ && (user === undefined || row.user_id === user)
12
+ && (!since || row.created_at >= since)
13
+ && (!until || row.created_at < until);
14
+
15
+ /** In memory, for one process: development, tests, or a server that may forget its usage on a restart. */
16
+ export function memoryLedger() {
17
+ const rows = [];
18
+ return {
19
+ async record(row) { rows.push(row); },
20
+ async count(filter) { return rows.filter((r) => matches(r, filter)).length; },
21
+ async rows(filter = {}) { return rows.filter((r) => matches(r, filter)); },
22
+ };
23
+ }
24
+
25
+ /** One JSON line per call in a file: for a single server, read by `npx rekhu-core usage --ledger <file>`. */
26
+ export function fileLedger(path) {
27
+ const all = async () => {
28
+ const text = await readFile(path, 'utf8').catch((error) => (error.code === 'ENOENT' ? '' : Promise.reject(error)));
29
+ return text.split('\n').filter(Boolean).map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
30
+ };
31
+ return {
32
+ async record(row) {
33
+ await mkdir(dirname(path), { recursive: true });
34
+ await appendFile(path, JSON.stringify(row) + '\n');
35
+ },
36
+ async count(filter) { return (await all()).filter((r) => matches(r, filter)).length; },
37
+ async rows(filter = {}) { return (await all()).filter((r) => matches(r, filter)); },
38
+ };
39
+ }
40
+
41
+ /** The usage of a month per tenant, knower and action: calls, model calls, Rekhu's cost and the app's price. */
42
+ export async function usage(ledger, { month, tenant } = {}) {
43
+ if (typeof ledger.rows !== 'function') throw new Error('this ledger cannot list its rows; read the usage from your own table');
44
+ const m = month || new Date().toISOString().slice(0, 7);
45
+ if (!/^\d{4}-\d{2}$/.test(m)) throw new Error('the month is YYYY-MM');
46
+ const since = `${m}-01T00:00:00.000Z`;
47
+ const [y, mo] = m.split('-').map(Number);
48
+ const until = new Date(Date.UTC(mo === 12 ? y + 1 : y, mo === 12 ? 0 : mo, 1)).toISOString();
49
+ const groups = new Map();
50
+ for (const r of await ledger.rows({ since, until, ...(tenant !== undefined ? { tenant } : {}) })) {
51
+ const key = [r.tenant_id, r.knower, r.action].join('|');
52
+ const g = groups.get(key) || { tenant: r.tenant_id, name: r.tenant_name, knower: r.knower, action: r.action, calls: 0, model_calls: 0, cost_usd: 0, price: null };
53
+ g.calls += 1;
54
+ g.model_calls += Number(r.model_calls || 0);
55
+ g.cost_usd = Math.round((g.cost_usd + Number(r.cost_usd || 0)) * 1e6) / 1e6;
56
+ if (r.price !== null && r.price !== undefined) g.price = Math.round(((g.price || 0) + Number(r.price)) * 100) / 100;
57
+ groups.set(key, g);
58
+ }
59
+ return [...groups.values()].sort((a, b) => `${a.tenant}${a.knower}${a.action}`.localeCompare(`${b.tenant}${b.knower}${b.action}`));
60
+ }