altalandi 0.6.1 → 0.6.3

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/client.mjs CHANGED
@@ -1,219 +1,260 @@
1
- const DEFAULT_BASE_URL = "https://altalandi.is/api";
2
- const DEFAULT_TIMEOUT_MS = 300_000;
3
- const REQUEST_FIELDS = [
4
- "sourceLang",
5
- "domain",
6
- "register",
7
- "preserveMeaning",
8
- "scoreNativeness",
9
- "onlyImprove",
10
- "polishPass",
11
- "writer",
12
- "scoring",
13
- "glossary",
14
- "returnDiffs",
15
- "contribute",
16
- ];
17
-
18
- export class AltalandiError extends Error {
19
- constructor(message, { code = "ALTALANDI_ERROR", status, retryAfterMs } = {}) {
20
- super(message);
21
- this.name = "AltalandiError";
22
- this.code = code;
23
- if (Number.isInteger(status)) this.status = status;
24
- if (Number.isFinite(retryAfterMs)) this.retryAfterMs = retryAfterMs;
25
- }
26
- }
27
-
28
- /** Retry-After as milliseconds: delay-seconds or an HTTP date. */
29
- function retryAfterMs(response) {
30
- const value = response?.headers?.get?.("retry-after");
31
- if (!value) return undefined;
32
- if (/^\s*\d+\s*$/.test(value)) return Number(value) * 1000;
33
- const at = Date.parse(value);
34
- return Number.isFinite(at) ? Math.max(0, at - Date.now()) : undefined;
35
- }
36
-
37
- export class MeaningVerificationError extends AltalandiError {
38
- constructor(verdict) {
39
- const changed = verdict === false;
40
- super(
41
- changed
42
- ? "Altalandi could not preserve the input meaning."
43
- : "Altalandi did not return a verified meaning verdict.",
44
- { code: changed ? "MEANING_NOT_PRESERVED" : "MEANING_UNVERIFIED" },
45
- );
46
- this.name = "MeaningVerificationError";
47
- this.verdict = verdict === false ? false : null;
48
- }
49
- }
50
-
51
- function env(name) {
52
- if (typeof process === "undefined" || !process.env) return undefined;
53
- return process.env[name];
54
- }
55
-
56
- function cleanText(value, secret = "") {
57
- if (typeof value !== "string") return "";
58
- let cleaned = value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
59
- if (secret) cleaned = cleaned.split(secret).join("[redacted]");
60
- return cleaned.slice(0, 300);
61
- }
62
-
63
- function errorDetail(payload) {
64
- const detail = payload?.detail;
65
- if (typeof detail === "string") return detail;
66
- if (Array.isArray(detail)) {
67
- return detail
68
- .map((entry) => (typeof entry?.msg === "string" ? entry.msg : ""))
69
- .filter(Boolean)
70
- .join("; ");
71
- }
72
- return "";
73
- }
74
-
75
- function requestBody(text, options) {
76
- const body = { text };
77
- for (const field of REQUEST_FIELDS) {
78
- if (options[field] !== undefined) body[field] = options[field];
79
- }
80
- return body;
81
- }
82
-
83
- /**
84
- * Refine English or Icelandic text through the hosted Altalandi API.
85
- * Client settings (`apiKey`, `baseUrl`, `timeoutMs`, `fetch`) are never sent in
86
- * the JSON body. The full API result is returned without weakening its verdict.
87
- */
88
- export async function refine(text, options = {}) {
89
- if (typeof text !== "string" || !text.trim()) {
90
- throw new AltalandiError("Altalandi requires non-empty text.", { code: "INVALID_INPUT" });
91
- }
92
-
93
- const explicitKey = options.apiKey;
94
- const selectedKey = explicitKey === undefined ? env("ALTALANDI_API_KEY") : explicitKey;
95
- if (selectedKey !== undefined && typeof selectedKey !== "string") {
96
- throw new AltalandiError("Altalandi apiKey must be a string.", { code: "INVALID_CONFIG" });
97
- }
98
- const apiKey = selectedKey?.trim();
99
- if (!apiKey) {
100
- throw new AltalandiError(
101
- "No Altalandi API key. Set ALTALANDI_API_KEY or pass apiKey explicitly.",
102
- { code: "MISSING_API_KEY" },
103
- );
104
- }
105
-
106
- const selectedBaseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
107
- if (typeof selectedBaseUrl !== "string") {
108
- throw new AltalandiError("Altalandi baseUrl must be a string.", { code: "INVALID_CONFIG" });
109
- }
110
- const baseUrl = selectedBaseUrl.trim().replace(/\/+$/, "");
111
- if (!baseUrl) {
112
- throw new AltalandiError("Altalandi baseUrl must not be empty.", { code: "INVALID_CONFIG" });
113
- }
114
-
115
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
116
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
117
- throw new AltalandiError("Altalandi timeoutMs must be a positive number.", {
118
- code: "INVALID_CONFIG",
119
- });
120
- }
121
-
122
- const fetchImpl = options.fetch ?? globalThis.fetch;
123
- if (typeof fetchImpl !== "function") {
124
- throw new AltalandiError("This runtime does not provide fetch.", { code: "INVALID_CONFIG" });
125
- }
126
-
127
- const controller = new AbortController();
128
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
129
- try {
130
- let response;
131
- try {
132
- response = await fetchImpl(`${baseUrl}/v1/refine`, {
133
- method: "POST",
134
- headers: {
135
- "content-type": "application/json",
136
- authorization: `Bearer ${apiKey}`,
137
- },
138
- body: JSON.stringify(requestBody(text, options)),
139
- signal: controller.signal,
140
- });
141
- } catch {
142
- if (controller.signal.aborted) {
143
- throw new AltalandiError(`Altalandi timed out after ${timeoutMs} ms.`, {
144
- code: "TIMEOUT",
145
- });
146
- }
147
- throw new AltalandiError("Altalandi could not reach the API.", { code: "NETWORK_ERROR" });
148
- }
149
-
150
- let raw = "";
151
- try {
152
- raw = await response.text();
153
- } catch {
154
- if (controller.signal.aborted) {
155
- throw new AltalandiError(`Altalandi timed out after ${timeoutMs} ms.`, {
156
- code: "TIMEOUT",
157
- });
158
- }
159
- if (response.ok) {
160
- throw new AltalandiError("Altalandi could not read the API response.", {
161
- code: "INVALID_RESPONSE",
162
- status: response.status,
163
- });
164
- }
165
- // The HTTP status remains useful when an error response has no readable body.
166
- }
167
-
168
- let payload;
169
- if (raw) {
170
- try {
171
- payload = JSON.parse(raw);
172
- } catch {
173
- if (response.ok) {
174
- throw new AltalandiError("Altalandi returned an invalid JSON response.", {
175
- code: "INVALID_RESPONSE",
176
- status: response.status,
177
- });
178
- }
179
- }
180
- }
181
-
182
- if (!response.ok) {
183
- const status = Number.isInteger(response.status) ? response.status : 0;
184
- const statusText = cleanText(response.statusText);
185
- const detail = cleanText(errorDetail(payload), apiKey);
186
- const suffix = detail || statusText;
187
- throw new AltalandiError(
188
- `Altalandi API request failed (${status || "unknown status"})${suffix ? `: ${suffix}` : "."}`,
189
- { code: "API_ERROR", status, retryAfterMs: retryAfterMs(response) },
190
- );
191
- }
192
-
193
- if (!payload || typeof payload !== "object" || Array.isArray(payload) || typeof payload.output !== "string") {
194
- throw new AltalandiError("Altalandi returned an invalid response shape.", {
195
- code: "INVALID_RESPONSE",
196
- status: response.status,
197
- });
198
- }
199
-
200
- return payload;
201
- } finally {
202
- clearTimeout(timeout);
203
- }
204
- }
205
-
206
- /** Assert that an existing result carries a completed, positive meaning check. */
207
- export function assertMeaningVerified(result) {
208
- if (result?.meaningPreserved !== true) {
209
- throw new MeaningVerificationError(result?.meaningPreserved);
210
- }
211
- return result;
212
- }
213
-
214
- /** Request meaning verification and reject unless the API returns strict true. */
215
- export async function refineVerified(text, options = {}) {
216
- const result = await refine(text, { ...options, preserveMeaning: true });
217
- assertMeaningVerified(result);
218
- return result;
219
- }
1
+ const DEFAULT_BASE_URL = "https://altalandi.is/api";
2
+ const DEFAULT_TIMEOUT_MS = 300_000;
3
+ const REQUEST_FIELDS = [
4
+ "sourceLang",
5
+ "domain",
6
+ "register",
7
+ "preserveMeaning",
8
+ "scoreNativeness",
9
+ "onlyImprove",
10
+ "polishPass",
11
+ "writer",
12
+ "scoring",
13
+ "glossary",
14
+ "returnDiffs",
15
+ "contribute",
16
+ "job",
17
+ ];
18
+ // /v1/refine/batch takes fewer options: no writer/scoring tiers, polish or diffs switch.
19
+ const BATCH_REQUEST_FIELDS = [
20
+ "sourceLang",
21
+ "domain",
22
+ "register",
23
+ "preserveMeaning",
24
+ "scoreNativeness",
25
+ "onlyImprove",
26
+ "glossary",
27
+ "autoTerminology",
28
+ "contribute",
29
+ "job",
30
+ ];
31
+
32
+ export class AltalandiError extends Error {
33
+ constructor(message, { code = "ALTALANDI_ERROR", status, retryAfterMs } = {}) {
34
+ super(message);
35
+ this.name = "AltalandiError";
36
+ this.code = code;
37
+ if (Number.isInteger(status)) this.status = status;
38
+ if (Number.isFinite(retryAfterMs)) this.retryAfterMs = retryAfterMs;
39
+ }
40
+ }
41
+
42
+ /** Retry-After as milliseconds: delay-seconds or an HTTP date. */
43
+ function retryAfterMs(response) {
44
+ const value = response?.headers?.get?.("retry-after");
45
+ if (!value) return undefined;
46
+ if (/^\s*\d+\s*$/.test(value)) return Number(value) * 1000;
47
+ const at = Date.parse(value);
48
+ return Number.isFinite(at) ? Math.max(0, at - Date.now()) : undefined;
49
+ }
50
+
51
+ export class MeaningVerificationError extends AltalandiError {
52
+ constructor(verdict) {
53
+ const changed = verdict === false;
54
+ super(
55
+ changed
56
+ ? "Altalandi could not preserve the input meaning."
57
+ : "Altalandi did not return a verified meaning verdict.",
58
+ { code: changed ? "MEANING_NOT_PRESERVED" : "MEANING_UNVERIFIED" },
59
+ );
60
+ this.name = "MeaningVerificationError";
61
+ this.verdict = verdict === false ? false : null;
62
+ }
63
+ }
64
+
65
+ function env(name) {
66
+ if (typeof process === "undefined" || !process.env) return undefined;
67
+ return process.env[name];
68
+ }
69
+
70
+ function cleanText(value, secret = "") {
71
+ if (typeof value !== "string") return "";
72
+ let cleaned = value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
73
+ if (secret) cleaned = cleaned.split(secret).join("[redacted]");
74
+ return cleaned.slice(0, 300);
75
+ }
76
+
77
+ function errorDetail(payload) {
78
+ const detail = payload?.detail;
79
+ if (typeof detail === "string") return detail;
80
+ if (Array.isArray(detail)) {
81
+ return detail
82
+ .map((entry) => (typeof entry?.msg === "string" ? entry.msg : ""))
83
+ .filter(Boolean)
84
+ .join("; ");
85
+ }
86
+ return "";
87
+ }
88
+
89
+ function requestBody(text, options) {
90
+ const body = { text };
91
+ for (const field of REQUEST_FIELDS) {
92
+ if (options[field] !== undefined) body[field] = options[field];
93
+ }
94
+ return body;
95
+ }
96
+
97
+ /** POST a JSON body to `${baseUrl}${route}` with the key, base URL, timeout and
98
+ * error handling every call shares. Returns the payload when `validShape`
99
+ * accepts it; throws an AltalandiError (with status and Retry-After) otherwise. */
100
+ async function post(route, body, options, validShape) {
101
+ const explicitKey = options.apiKey;
102
+ const selectedKey = explicitKey === undefined ? env("ALTALANDI_API_KEY") : explicitKey;
103
+ if (selectedKey !== undefined && typeof selectedKey !== "string") {
104
+ throw new AltalandiError("Altalandi apiKey must be a string.", { code: "INVALID_CONFIG" });
105
+ }
106
+ const apiKey = selectedKey?.trim();
107
+ if (!apiKey) {
108
+ throw new AltalandiError(
109
+ "No Altalandi API key. Set ALTALANDI_API_KEY or pass apiKey explicitly.",
110
+ { code: "MISSING_API_KEY" },
111
+ );
112
+ }
113
+
114
+ const selectedBaseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
115
+ if (typeof selectedBaseUrl !== "string") {
116
+ throw new AltalandiError("Altalandi baseUrl must be a string.", { code: "INVALID_CONFIG" });
117
+ }
118
+ const baseUrl = selectedBaseUrl.trim().replace(/\/+$/, "");
119
+ if (!baseUrl) {
120
+ throw new AltalandiError("Altalandi baseUrl must not be empty.", { code: "INVALID_CONFIG" });
121
+ }
122
+
123
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
124
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
125
+ throw new AltalandiError("Altalandi timeoutMs must be a positive number.", {
126
+ code: "INVALID_CONFIG",
127
+ });
128
+ }
129
+
130
+ const fetchImpl = options.fetch ?? globalThis.fetch;
131
+ if (typeof fetchImpl !== "function") {
132
+ throw new AltalandiError("This runtime does not provide fetch.", { code: "INVALID_CONFIG" });
133
+ }
134
+
135
+ const controller = new AbortController();
136
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
137
+ try {
138
+ let response;
139
+ try {
140
+ response = await fetchImpl(`${baseUrl}${route}`, {
141
+ method: "POST",
142
+ headers: {
143
+ "content-type": "application/json",
144
+ authorization: `Bearer ${apiKey}`,
145
+ },
146
+ body: JSON.stringify(body),
147
+ signal: controller.signal,
148
+ });
149
+ } catch {
150
+ if (controller.signal.aborted) {
151
+ throw new AltalandiError(`Altalandi timed out after ${timeoutMs} ms.`, {
152
+ code: "TIMEOUT",
153
+ });
154
+ }
155
+ throw new AltalandiError("Altalandi could not reach the API.", { code: "NETWORK_ERROR" });
156
+ }
157
+
158
+ let raw = "";
159
+ try {
160
+ raw = await response.text();
161
+ } catch {
162
+ if (controller.signal.aborted) {
163
+ throw new AltalandiError(`Altalandi timed out after ${timeoutMs} ms.`, {
164
+ code: "TIMEOUT",
165
+ });
166
+ }
167
+ if (response.ok) {
168
+ throw new AltalandiError("Altalandi could not read the API response.", {
169
+ code: "INVALID_RESPONSE",
170
+ status: response.status,
171
+ });
172
+ }
173
+ // The HTTP status remains useful when an error response has no readable body.
174
+ }
175
+
176
+ let payload;
177
+ if (raw) {
178
+ try {
179
+ payload = JSON.parse(raw);
180
+ } catch {
181
+ if (response.ok) {
182
+ throw new AltalandiError("Altalandi returned an invalid JSON response.", {
183
+ code: "INVALID_RESPONSE",
184
+ status: response.status,
185
+ });
186
+ }
187
+ }
188
+ }
189
+
190
+ if (!response.ok) {
191
+ const status = Number.isInteger(response.status) ? response.status : 0;
192
+ const statusText = cleanText(response.statusText);
193
+ const detail = cleanText(errorDetail(payload), apiKey);
194
+ const suffix = detail || statusText;
195
+ throw new AltalandiError(
196
+ `Altalandi API request failed (${status || "unknown status"})${suffix ? `: ${suffix}` : "."}`,
197
+ { code: "API_ERROR", status, retryAfterMs: retryAfterMs(response) },
198
+ );
199
+ }
200
+
201
+ if (!payload || typeof payload !== "object" || Array.isArray(payload) || !validShape(payload)) {
202
+ throw new AltalandiError("Altalandi returned an invalid response shape.", {
203
+ code: "INVALID_RESPONSE",
204
+ status: response.status,
205
+ });
206
+ }
207
+
208
+ return payload;
209
+ } finally {
210
+ clearTimeout(timeout);
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Refine English or Icelandic text through the hosted Altalandi API.
216
+ * Client settings (`apiKey`, `baseUrl`, `timeoutMs`, `fetch`) are never sent in
217
+ * the JSON body. The full API result is returned without weakening its verdict.
218
+ */
219
+ export async function refine(text, options = {}) {
220
+ if (typeof text !== "string" || !text.trim()) {
221
+ throw new AltalandiError("Altalandi requires non-empty text.", { code: "INVALID_INPUT" });
222
+ }
223
+ return post("/v1/refine", requestBody(text, options), options, (p) => typeof p.output === "string");
224
+ }
225
+
226
+ /**
227
+ * Refine many short strings in one call (POST /v1/refine/batch): at most 500
228
+ * items of at most 2,000 characters each, 20,000 characters together. Each item
229
+ * comes back with its own output and meaning verdict; an item with `error` came
230
+ * back unchanged and was not billed. Same key, limits and errors as refine().
231
+ */
232
+ export async function refineBatch(items, options = {}) {
233
+ if (!Array.isArray(items) || !items.length
234
+ || !items.every((it) => it && typeof it.text === "string" && (it.id === undefined || typeof it.id === "string"))) {
235
+ throw new AltalandiError("Altalandi refineBatch requires a non-empty list of { id?, text } items.", {
236
+ code: "INVALID_INPUT",
237
+ });
238
+ }
239
+ const body = { items: items.map((it) => (it.id === undefined ? { text: it.text } : { id: it.id, text: it.text })) };
240
+ for (const field of BATCH_REQUEST_FIELDS) {
241
+ if (options[field] !== undefined) body[field] = options[field];
242
+ }
243
+ return post("/v1/refine/batch", body, options, (p) => Array.isArray(p.items)
244
+ && p.items.every((it) => it && typeof it === "object" && typeof it.output === "string"));
245
+ }
246
+
247
+ /** Assert that an existing result carries a completed, positive meaning check. */
248
+ export function assertMeaningVerified(result) {
249
+ if (result?.meaningPreserved !== true) {
250
+ throw new MeaningVerificationError(result?.meaningPreserved);
251
+ }
252
+ return result;
253
+ }
254
+
255
+ /** Request meaning verification and reject unless the API returns strict true. */
256
+ export async function refineVerified(text, options = {}) {
257
+ const result = await refine(text, { ...options, preserveMeaning: true });
258
+ assertMeaningVerified(result);
259
+ return result;
260
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altalandi",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Native-sounding Icelandic from your terminal or AI agent: one-time login, a Claude Code skill, and meaning verification.",
5
5
  "type": "module",
6
6
  "main": "./client.mjs",
package/report.mjs CHANGED
@@ -89,6 +89,7 @@ export function reportMarkdown(input, data, options = {}) {
89
89
  `| Innfæddni | ${q.nativenessScore ?? "n/a"}/100 |`,
90
90
  `| Ritill | ${tier.writer || "standard"} / ${tier.scoring || "?"} |`,
91
91
  `| Auðkenni | ${data?.runUrl ? `[${data.id}](${data.runUrl})` : data?.id || "?"} |`,
92
+ ...(data?.jobUrl ? [`| Verkið | ${data.jobUrl} |`] : []),
92
93
  "",
93
94
  "## Fyrir", "", fence(input), "",
94
95
  "## Eftir", "", fence(data?.output), "",
@@ -154,6 +155,64 @@ export function formatIsk(n) {
154
155
 
155
156
  const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
156
157
 
158
+ // ── characters (stafir) ─────────────────────────────────────────────────────
159
+ // Billing is in characters. Engines from before the switch send only word
160
+ // units; those are shown as characters too (units × charsPerWord, rounded to
161
+ // tens and marked "um"). The per-100-characters rate is never shown: amounts
162
+ // in krónur and friendly equivalents (an A4 page) only.
163
+
164
+ /** Characters per word, when an engine does not say (`charsPerWord`). */
165
+ export const CHARS_PER_WORD = 6.5;
166
+ /** A normal A4 page (display only). */
167
+ export const CHARS_PER_A4_PAGE = 3200;
168
+
169
+ const singular = (n) => { const k = Math.round(Math.abs(n)); return k % 10 === 1 && k % 100 !== 11; };
170
+
171
+ /** 2690 → "2.690 stafir", 21 → "21 stafur". */
172
+ export function formatChars(n) {
173
+ return `${formatInt(n)} ${singular(n) ? "stafur" : "stafir"}`;
174
+ }
175
+
176
+ /** One friendly equivalent: "um 7 A4-síður", "um 1 A4-síða", "innan við hálfa A4-síðu". */
177
+ export function charsEquivalent(n) {
178
+ const pages = Math.round(Number(n) / CHARS_PER_A4_PAGE);
179
+ if (pages < 1) return "innan við hálfa A4-síðu";
180
+ return `um ${formatInt(pages)} ${singular(pages) ? "A4-síða" : "A4-síður"}`;
181
+ }
182
+
183
+ /** Word units of an older engine as characters, rounded to tens. */
184
+ export function unitsToChars(units, charsPerWord = CHARS_PER_WORD) {
185
+ return Math.round((Number(units) * charsPerWord) / 10) * 10;
186
+ }
187
+
188
+ const perWord = (...from) => from.map((o) => num(o?.charsPerWord)).find((v) => v != null && v > 0) ?? CHARS_PER_WORD;
189
+
190
+ /** The first source with a characters field wins; else the first with word
191
+ * units, converted. → { n, approx } or null. */
192
+ function charsOf(sources, charsKey, unitsKey, cpw) {
193
+ for (const s of sources) if (num(s?.[charsKey]) != null) return { n: s[charsKey], approx: false };
194
+ for (const s of sources) if (num(s?.[unitsKey]) != null) return { n: unitsToChars(s[unitsKey], cpw), approx: true };
195
+ return null;
196
+ }
197
+
198
+ /** Today's free allowance: "ókeypis eftir í dag 9.800 af 15.000 stöfum".
199
+ * `billing` is a call's billing block, `after`/`before` the /v1/me answers
200
+ * after and before it; any may be null. "" when nothing is known. */
201
+ export function freeCharsText(billing, after = null, before = null) {
202
+ const cpw = perWord(billing, after, before);
203
+ const left = charsOf([billing, after], "freeCharsLeftToday", "freeUnitsLeftToday", cpw);
204
+ const daily = charsOf([after, before], "freeDailyChars", "freeDailyUnits", cpw);
205
+ // One "um", before the first converted number that is not 0.
206
+ const um = (c) => (c?.approx && c.n ? "um " : "");
207
+ if (left && daily) {
208
+ const [a, b] = um(left) ? ["um ", ""] : ["", um(daily)];
209
+ return `ókeypis eftir í dag ${a}${formatInt(left.n)} af ${b}${formatInt(daily.n)} ${singular(daily.n) ? "staf" : "stöfum"}`;
210
+ }
211
+ if (left) return `ókeypis eftir í dag ${um(left)}${formatChars(left.n)}`;
212
+ if (daily && daily.n > 0) return `ókeypis ${um(daily)}${formatChars(daily.n)} á dag`;
213
+ return "";
214
+ }
215
+
157
216
  /**
158
217
  * What the call cost and what is left. `data.billing` (sent by newer engines)
159
218
  * is exact; otherwise the cost is the balance difference between /v1/me
@@ -167,24 +226,21 @@ export function costLines(data, before, after) {
167
226
  if (charged == null && num(before?.balanceIsk) != null && num(after?.balanceIsk) != null) {
168
227
  charged = Math.round((before.balanceIsk - after.balanceIsk) * 100) / 100;
169
228
  }
170
- const units = num(b.units);
171
- const unitsText = units != null ? ` · ${formatInt(units)} einingar` : "";
229
+ const chars = charsOf([b, data], "characters", "units", perWord(b, data, after, before));
230
+ const charsText = chars ? ` · ${chars.approx ? "um " : ""}${formatChars(chars.n)}` : "";
172
231
  const lines = [];
173
- if (charged == null) lines.push(`- Þetta kall: kostnaður óþekktur${unitsText}`);
174
- else if (charged < 0) lines.push(`- Þetta kall: ekki hægt að reikna (inneignin breyttist á meðan)${unitsText}`);
232
+ if (charged == null) lines.push(`- Þetta kall: kostnaður óþekktur${charsText}`);
233
+ else if (charged < 0) lines.push(`- Þetta kall: ekki hægt að reikna (inneignin breyttist á meðan)${charsText}`);
175
234
  else if (charged === 0) lines.push(b.paidFrom === "credit"
176
- ? `- Þetta kall: 0 kr. af inneign${unitsText}`
177
- : `- Þetta kall: 0 kr., ókeypis dagskammtur${unitsText}`);
178
- else lines.push(`- Þetta kall: ${formatIsk(charged)} af inneign${exact ? "" : " (mismunur á inneign fyrir og eftir)"}${unitsText}`);
235
+ ? `- Þetta kall: 0 kr. af inneign${charsText}`
236
+ : `- Þetta kall: 0 kr., ókeypis dagskammtur${charsText}`);
237
+ else lines.push(`- Þetta kall: ${formatIsk(charged)} af inneign${exact ? "" : " (mismunur á inneign fyrir og eftir)"}${charsText}`);
179
238
 
180
239
  const balance = num(b.balanceIsk) ?? num(after?.balanceIsk);
181
- const left = num(b.freeUnitsLeftToday) ?? num(after?.freeUnitsLeftToday);
182
- const daily = num(after?.freeDailyUnits) ?? num(before?.freeDailyUnits);
240
+ const free = freeCharsText(b, after, before);
183
241
  const parts = [];
184
242
  if (balance != null) parts.push(`inneign ${formatIsk(balance)}`);
185
- if (left != null && daily != null) parts.push(`ókeypis eftir í dag ${formatInt(left)} af ${formatInt(daily)} einingum`);
186
- else if (left != null) parts.push(`ókeypis eftir í dag ${formatInt(left)} einingar`);
187
- else if (daily != null && daily > 0) parts.push(`ókeypis ${formatInt(daily)} einingar á dag`);
243
+ if (free) parts.push(free);
188
244
  if (parts.length) lines.push(`- Staða: ${parts.join(" · ")}`);
189
245
  else lines.push("- Staða: náðist ekki að sækja (`npx altalandi whoami`)");
190
246
  if (balance != null && balance < 50) lines.push("- Bæta við inneign: https://altalandi.is/stjornbord/inneign");
@@ -280,7 +336,9 @@ export function reviewMarkdown(input, data, { before = null, after = null, maxCh
280
336
 
281
337
  out.push("**Kostnaður**", ...costLines(data, before, after));
282
338
  // The run's own page: the text sent, the result, the checks and the review.
283
- if (data?.runUrl) out.push(`- Verkið: ${data.runUrl}`);
339
+ // A call sent as part of a job (--job) links the job first, then this part.
340
+ if (data?.jobUrl) out.push(`- Verkið: ${data.jobUrl}`);
341
+ if (data?.runUrl) out.push(`- ${data?.jobUrl ? "Hlutinn" : "Verkið"}: ${data.runUrl}`);
284
342
  else if (data?.id) out.push(`- Auðkenni: \`${data.id}\``);
285
343
  return out.join("\n") + "\n";
286
344
  }
@@ -329,8 +387,12 @@ export function batchReportMarkdown(summary) {
329
387
  `| Kaflar | ${count(t.passages)}: ${count(t.changed)} breyttir, ${count(t.unchangedPassages)} þurftu ekki breytingu, `
330
388
  + `${count(t.kept)} héldu frumtextanum, ${count(t.failed)} mistókust |`,
331
389
  `| Stafir sendir | ${count(t.characters)} |`,
390
+ ...(Number.isFinite(t.billedCharacters)
391
+ ? [`| Stafir rukkaðir | ${t.billedCharactersApprox ? "um " : ""}${count(t.billedCharacters)} |`] : []),
332
392
  `| Tegund texta | ${cell(summary?.domain || "general")} |`,
333
393
  `| Málsnið | ${cell(summary?.register || "neutral")} |`,
394
+ // The run's one job page (an engine that knows jobs); never one link per call.
395
+ ...(summary?.jobUrl ? [`| Verkið | ${summary.jobUrl} |`] : []),
334
396
  ];
335
397
 
336
398
  if (summary?.stopped) {