altalandi 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -107,8 +107,8 @@ altalandi --report --file draft.txt # also write a Markdown report to ./altal
107
107
  | `--register` | `neutral` | `formal` \| `neutral` \| `warm` |
108
108
  | `--json` | off | print the full API response |
109
109
  | `--diff` | off | print the text before and after, then each change with its reason, on `stderr` (coloured in a terminal; `NO_COLOR` turns colour off) |
110
- | `--review` | off | print one Markdown block on `stdout`, made to be shown as it is: the verdict, the text before and after, each change with its reason, what the call cost and the balance and free units left. Exits on the outcome: `0` rewritten and verified or no change needed, `3` refused or unverified, `4` out of credit |
111
- | `--no-store` | off | send `contribute: false`: this text is never kept. Without it, free use (calls covered by the free daily units) is kept with personal data scrubbed, to improve the service; paid use is kept only with consent ([privacy](https://altalandi.is/personuvernd#textabanki)) |
110
+ | `--review` | off | print one Markdown block on `stdout`, made to be shown as it is: the verdict, the text before and after, each change with its reason, what the call cost and the balance and free characters left. Exits on the outcome: `0` rewritten and verified or no change needed, `3` refused or unverified, `4` out of credit |
111
+ | `--no-store` | off | send `contribute: false`: this text is never kept. Without it, free use (calls covered by the free daily characters) is kept with personal data scrubbed, to improve the service; paid use is kept only with consent ([privacy](https://altalandi.is/personuvernd#textabanki)) |
112
112
  | `--out <file>` | | write the refined text to `<file>` (UTF-8); with `--review` the block names it when the text is shortened |
113
113
  | `--report` | off | also write `./altalandi-reports/altalandi-<time>-<id>.md`: input, output, a table of every change (before, after, reason, layer), warnings and the verdict |
114
114
  | `--report-dir <dir>` | | write the report to `<dir>` instead (implies `--report`) |
@@ -127,8 +127,11 @@ altalandi https://example.com/pricing --out pricing-is.md # one page → Markd
127
127
  altalandi https://example.com/docs/ --site --max-pages 50 # a site → ./example.com-is/
128
128
  ```
129
129
 
130
- Each passage (a paragraph, list item, table cell, heading or string) is its own
131
- call. Everything outside the passages is written back byte for byte:
130
+ Each passage (a paragraph, list item, table cell, heading or string) is refined
131
+ and checked on its own. Short passages (up to 1,500 characters) are sent
132
+ together through `/v1/refine/batch`, up to 40 a request and 6,000 characters;
133
+ longer ones go one call each to `/v1/refine`. Everything outside the passages is
134
+ written back byte for byte:
132
135
 
133
136
  | format | refined | never touched |
134
137
  |--------|---------|---------------|
@@ -154,13 +157,19 @@ the same origin and under the URL's folder.
154
157
  from the public tariff:
155
158
 
156
159
  ```text
157
- 5 files, 24 passages, about 851 characters (23 calls: repeated passages are sent once).
160
+ 5 files, 24 passages, about 851 characters (23 to send: repeated passages are sent once).
161
+ 1 request: 23 short passages together in 1 batch.
158
162
  1 more file has nothing to refine and is copied unchanged.
159
- Billing: about 920 units (at least 40 a call), about 74 kr if paid from credit. The free allowance is 2,000 units a day.
163
+ Kostnaður: um 3.016 stafir (um 1 A4-síða), um 30 kr. ef greitt er af inneign.
164
+ Hvert kall telst minnst 250 stafir; margir stuttir textar í einu kalli minnst 130 stafir hver. Hver aðgangur fær ókeypis dagskammt af stöfum (`GET /v1/pricing`).
160
165
  ```
161
166
 
162
- Every call costs at least the per-call minimum, so many short strings cost more
163
- than their word count. In a terminal it asks before sending; without one (CI,
167
+ Billing is by character (stafir): every character sent, spaces and punctuation
168
+ included. In a batch each string is billed at least 130 characters, and the
169
+ 250-character per-call minimum is paid once per request, not once per string:
170
+ short strings cost about half what one call each would. The summary and the
171
+ report show the characters actually billed. An older self-hosted engine without the batch endpoint gets one
172
+ call per passage (the run says so once). In a terminal it asks before sending; without one (CI,
164
173
  agents) it sends nothing unless you pass `--yes`.
165
174
 
166
175
  | flag | default | meaning |
@@ -169,7 +178,7 @@ agents) it sends nothing unless you pass `--yes`.
169
178
  | `--dry-run` | off | list every passage and the estimate; send nothing, write nothing |
170
179
  | `--yes`, `-y` | off | send without asking |
171
180
  | `--max-chars <n>` | | files are sent in order until the next would go over n characters; the rest are neither sent nor written |
172
- | `--concurrency <n>` | `2` | calls at once, 1–4 |
181
+ | `--concurrency <n>` | `2` | requests at once, 1–4 |
173
182
  | `--site` / `--max-pages <n>` | off / `20` | read a site from the URL, up to n pages |
174
183
  | `--no-store` | off | keep none of the passages (free use is otherwise kept, scrubbed of personal data) |
175
184
  | `--json` | off | a machine-readable summary of every file and passage on stdout |
@@ -255,6 +264,6 @@ the explicit verification opt-out and exits 0 after a successful API response.
255
264
 
256
265
  MIT licensed. Calls are paid from the account's prepaid credit (a 500 kr trial,
257
266
  then credit bought in the portal); a call the credit cannot cover exits with the
258
- server's `402 insufficient_balance` message. The request's text and glossary fields are charged at the effective billing-tier
259
- weight. Each whitespace-delimited token is at least one unit; an unbroken token
260
- uses one unit per 20 characters, rounded up.
267
+ server's `402 insufficient_balance` message. The request's text and glossary fields are charged by the character
268
+ (every Unicode character, spaces, punctuation and line breaks included) at the
269
+ effective billing-tier weight, with a minimum per call.
package/auth.mjs CHANGED
@@ -211,10 +211,8 @@ export async function runWhoami(flags = {}, io = {}) {
211
211
  const me = await res.json();
212
212
  if (flags.json) out(JSON.stringify({ ...me, keySource: source }, null, 2));
213
213
  else {
214
- const { formatInt, formatIsk } = await import("./report.mjs");
215
- const free = me.freeUnitsLeftToday != null && me.freeDailyUnits != null
216
- ? `ókeypis eftir í dag ${formatInt(me.freeUnitsLeftToday)} af ${formatInt(me.freeDailyUnits)} einingum`
217
- : me.freeDailyUnits ? `ókeypis ${formatInt(me.freeDailyUnits)} einingar á dag` : "";
214
+ const { formatIsk, freeCharsText } = await import("./report.mjs");
215
+ const free = freeCharsText(null, me);
218
216
  out([me.username ?? "(lykill án aðgangs)",
219
217
  `inneign ${me.balanceIsk != null ? formatIsk(me.balanceIsk) : "—"}`,
220
218
  free, `lykill ${me.keyId} (${source})`].filter(Boolean).join(" · "));
package/batch.mjs CHANGED
@@ -4,12 +4,13 @@
4
4
  // The order of things is the safety story:
5
5
  // 1. plan read every document and cut it into passages (segment.mjs,
6
6
  // web.mjs); nothing is sent yet
7
- // 2. estimate files, passages, characters and billing units, printed before
8
- // anything is sent
7
+ // 2. estimate files, passages and the characters billed, with the price,
8
+ // printed before anything is sent
9
9
  // 3. consent a y/N question in a terminal, or --yes; with neither, nothing
10
10
  // is sent (--dry-run stops here on purpose)
11
- // 4. refine one call per passage, 2 at a time by default; a 429 waits for
12
- // Retry-After, a 402 stops the run cleanly
11
+ // 4. refine short passages go together in batch requests (/v1/refine/batch),
12
+ // longer ones one call each; 2 requests at a time by default; a
13
+ // 429 waits for Retry-After, a 402 stops the run cleanly
13
14
  // 5. write each file once all its passages are back; a passage whose
14
15
  // meaning check did not pass keeps its original text; nothing is
15
16
  // ever written inside the input
@@ -22,8 +23,8 @@ import { existsSync, statSync } from "node:fs";
22
23
  import { mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises";
23
24
  import path from "node:path";
24
25
 
25
- import { refine } from "./client.mjs";
26
- import { writeBatchReport } from "./report.mjs";
26
+ import { refine, refineBatch } from "./client.mjs";
27
+ import { CHARS_PER_WORD, charsEquivalent, formatChars, formatIsk, writeBatchReport } from "./report.mjs";
27
28
  import { assemble, FORMATS, isConfigJson, repairCodeSpans, segment, segmentMarkdown, splitForApi } from "./segment.mjs";
28
29
  import { pagePath, readPage, readSite, safeSegment, userAgent } from "./web.mjs";
29
30
 
@@ -39,6 +40,18 @@ const MAX_RETRY_WAIT_MS = 5 * 60_000; // a longer Retry-After means "come back l
39
40
  const MAX_RATE_LIMIT_RETRIES = 8;
40
41
  const MAX_TRANSIENT_RETRIES = 2;
41
42
  const ERRORS_IN_A_ROW_TO_STOP = 3;
43
+ // Short passages go to /v1/refine/batch: each is billed at least 130 characters
44
+ // there instead of the 250-character call minimum, which is paid once per
45
+ // request. A request
46
+ // stays small enough to come back well inside the web proxy's ~300 s timeout
47
+ // (the server refines 4 at a time: ~40 short items take a minute or two).
48
+ export const BATCH_PASSAGE_MAX_CHARS = 1500;
49
+ export const BATCH_MAX_ITEMS = 40;
50
+ export const BATCH_MAX_CHARS = 6000;
51
+ // When /v1/pricing says neither (minimumBillableChars / batchItemMinimumChars)
52
+ // nor the word units of an older engine.
53
+ const MIN_BILLABLE_CHARS = 250;
54
+ const BATCH_ITEM_MIN_CHARS = 130;
42
55
  const DOMAINS = ["general", "marketing", "data-analysis", "childrens-fiction"];
43
56
  const REGISTERS = ["formal", "neutral", "warm"];
44
57
 
@@ -60,10 +73,9 @@ const preview = (s, n = 72) => {
60
73
  return one.length > n ? `${one.slice(0, n - 1)}…` : one;
61
74
  };
62
75
 
63
- /** Every whitespace token is at least one billing unit; a long unbroken token
64
- * is one unit per 20 characters (the server's rule, docs/api/README.md). */
65
- const billingTokens = (text) => text.split(/\s+/).filter(Boolean)
66
- .reduce((n, t) => n + Math.max(1, Math.ceil(t.length / 20)), 0);
76
+ /** Billable characters: every Unicode code point of the trimmed text, spaces,
77
+ * punctuation and line breaks included (the server's rule). */
78
+ export const billableChars = (text) => [...String(text).trim()].length;
67
79
 
68
80
  /** The real path of p, or of its deepest existing ancestor plus the rest, so a
69
81
  * symlinked --out is compared where it really points. */
@@ -222,6 +234,31 @@ function buildCalls(files, maxChars) {
222
234
  return [...calls.values()];
223
235
  }
224
236
 
237
+ /** The requests of a run: short calls together in batches of at most
238
+ * BATCH_MAX_ITEMS items and BATCH_MAX_CHARS characters, longer ones alone. */
239
+ export function groupCalls(calls) {
240
+ const jobs = [];
241
+ let open = null;
242
+ for (const call of calls) {
243
+ if (call.text.length > BATCH_PASSAGE_MAX_CHARS) { jobs.push({ batch: false, calls: [call] }); continue; }
244
+ if (open && (open.calls.length >= BATCH_MAX_ITEMS || open.chars + call.text.length > BATCH_MAX_CHARS)) open = null;
245
+ if (!open) { open = { batch: true, calls: [], chars: 0 }; jobs.push(open); }
246
+ open.calls.push(call);
247
+ open.chars += call.text.length;
248
+ }
249
+ return jobs;
250
+ }
251
+
252
+ /** Characters a request is billed: the call minimum for one text; for a batch,
253
+ * each item at least the item minimum, the call minimum once for the request. */
254
+ function jobChars(job, pricing) {
255
+ if (!job.batch) return Math.max(pricing.minChars, billableChars(job.calls[0].text));
256
+ const items = job.calls.reduce((n, c) => n + Math.max(pricing.itemMinChars, billableChars(c.text)), 0);
257
+ return Math.max(pricing.minChars, items);
258
+ }
259
+
260
+ const fin = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
261
+
225
262
  async function fetchPricing(baseUrl, fetchImpl) {
226
263
  const controller = new AbortController();
227
264
  const timer = setTimeout(() => controller.abort(), 5000);
@@ -230,12 +267,20 @@ async function fetchPricing(baseUrl, fetchImpl) {
230
267
  if (!res.ok) return null;
231
268
  const p = JSON.parse(await res.text());
232
269
  const lane = p?.tiers?.standard?.fast;
233
- if (!Number.isFinite(p?.minimumBillableUnits) || !lane) return null;
270
+ if (!lane || typeof lane !== "object") return null;
271
+ // Characters (billingUnit "character"); an engine from before the switch
272
+ // gives word units, converted with charsPerWord.
273
+ const cpw = fin(p.charsPerWord) > 0 ? p.charsPerWord : CHARS_PER_WORD;
274
+ const chars = (c, u) => fin(p[c]) ?? (fin(p[u]) != null ? Math.round(p[u] * cpw) : null);
275
+ // The tier's price already carries its weight, as iskPer1000Units did.
276
+ const per100 = fin(lane.iskPer100Chars) ?? (fin(lane.iskPer1000Units) != null ? lane.iskPer1000Units / 10 / cpw : null);
234
277
  return {
235
- minUnits: p.minimumBillableUnits,
278
+ minChars: chars("minimumBillableChars", "minimumBillableUnits") ?? MIN_BILLABLE_CHARS,
279
+ itemMinChars: chars("batchItemMinimumChars", "batchItemMinimumUnits") ?? BATCH_ITEM_MIN_CHARS,
236
280
  multiplier: Number(lane.multiplier) || 1,
237
- iskPer1000: Number(lane.iskPer1000Units),
238
- freeDaily: Number(p.accountFreeDailyUnits) || 0,
281
+ iskPerChar: per100 != null ? per100 / 100 : null,
282
+ freeDaily: chars("accountFreeDailyChars", "accountFreeDailyUnits") ?? 0,
283
+ charsPerWord: cpw,
239
284
  };
240
285
  } catch {
241
286
  return null;
@@ -244,14 +289,22 @@ async function fetchPricing(baseUrl, fetchImpl) {
244
289
  }
245
290
  }
246
291
 
247
- function estimateLines(plan, calls, pricing, opts) {
292
+ function estimateLines(plan, calls, jobs, pricing, opts) {
248
293
  const refining = plan.files.filter((f) => f.status === "refine");
249
294
  const passages = refining.reduce((n, f) => n + f.units.length, 0);
250
295
  const chars = calls.reduce((n, c) => n + c.text.length, 0);
251
296
  const lines = [
252
297
  `${plural(refining.length, "file", "files")}, ${plural(passages, "passage", "passages")}, about ${fmt(chars)} characters`
253
- + `${calls.length !== passages ? ` (${plural(calls.length, "call", "calls")}: repeated passages are sent once)` : ""}.`,
298
+ + `${calls.length !== passages ? ` (${fmt(calls.length)} to send: repeated passages are sent once)` : ""}.`,
254
299
  ];
300
+ const batches = jobs.filter((j) => j.batch);
301
+ if (calls.length) {
302
+ const together = batches.reduce((n, j) => n + j.calls.length, 0);
303
+ const alone = jobs.length - batches.length;
304
+ lines.push(`${plural(jobs.length, "request", "requests")}: `
305
+ + [together && `${plural(together, "short passage", "short passages")} together in ${plural(batches.length, "batch", "batches")}`,
306
+ alone && `${plural(alone, "longer passage", "longer passages")} one by one`].filter(Boolean).join(", ") + ".");
307
+ }
255
308
  const copied = plan.files.filter((f) => f.status === "copy").length;
256
309
  if (copied) lines.push(`${plural(copied, "more file has", "more files have")} nothing to refine and ${copied === 1 ? "is" : "are"} copied unchanged.`);
257
310
  const over = plan.files.filter((f) => f.status === "over-budget");
@@ -260,10 +313,16 @@ function estimateLines(plan, calls, pricing, opts) {
260
313
  + `(from ${over[0].path} on).`);
261
314
  }
262
315
  if (pricing && calls.length) {
263
- const units = calls.reduce((n, c) => n + Math.max(pricing.minUnits, billingTokens(c.text)), 0) * pricing.multiplier;
264
- const isk = Number.isFinite(pricing.iskPer1000) ? `, about ${fmt((units * pricing.iskPer1000) / 1000)} kr if paid from credit` : "";
265
- lines.push(`Billing: about ${fmt(units)} units (at least ${fmt(pricing.minUnits)} a call)${isk}.`
266
- + `${pricing.freeDaily ? ` The free allowance is ${fmt(pricing.freeDaily)} units a day.` : ""}`);
316
+ // In Icelandic, as on the site: characters, one equivalent and krónur;
317
+ // never the rate per 100 characters.
318
+ const raw = jobs.reduce((n, j) => n + jobChars(j, pricing), 0);
319
+ const billed = raw * pricing.multiplier;
320
+ const isk = pricing.iskPerChar != null ? raw * pricing.iskPerChar : null;
321
+ const price = isk != null ? `, um ${formatIsk(isk >= 10 ? Math.round(isk) : isk)} ef greitt er af inneign` : "";
322
+ lines.push(`Kostnaður: um ${formatChars(billed)} (${charsEquivalent(billed)})${price}.`);
323
+ const rule = `Hvert kall telst minnst ${formatChars(pricing.minChars)}`
324
+ + (batches.length ? `; margir stuttir textar í einu kalli minnst ${formatChars(pricing.itemMinChars)} hver.` : ".");
325
+ lines.push(rule + (pricing.freeDaily ? ` ${formatChars(pricing.freeDaily)} á dag eru ókeypis.` : ""));
267
326
  }
268
327
  return lines;
269
328
  }
@@ -369,6 +428,7 @@ export async function runBatch(opts, io = {}) {
369
428
  return EXIT_ERROR;
370
429
  }
371
430
  const calls = buildCalls(plan.files, opts.maxChars);
431
+ const jobs = groupCalls(calls);
372
432
  const baseUrl = (opts.baseUrl || "https://altalandi.is/api").replace(/\/+$/, "");
373
433
  const where = `${plan.label} → ${display(plan.output, cwd)}`;
374
434
  const refining = () => plan.files.filter((f) => f.status === "refine");
@@ -379,7 +439,7 @@ export async function runBatch(opts, io = {}) {
379
439
  }
380
440
  // The public tariff: no key, no text sent, nothing charged.
381
441
  const pricing = calls.length ? await fetchPricing(baseUrl, fetchImpl) : null;
382
- const estimate = estimateLines(plan, calls, pricing, opts);
442
+ const estimate = estimateLines(plan, calls, jobs, pricing, opts);
383
443
 
384
444
  if (opts.dryRun) {
385
445
  if (opts.json) {
@@ -434,7 +494,18 @@ export async function runBatch(opts, io = {}) {
434
494
  await writeFile(target, data);
435
495
  };
436
496
 
437
- const state = { stop: null, pausedUntil: 0, errorsInARow: 0 };
497
+ const state = { stop: null, pausedUntil: 0, errorsInARow: 0, noBatch: false, billed: null, billedChars: null, approx: false };
498
+ // What a response says it billed: characters, or the word units of an
499
+ // engine from before the switch (converted, and marked as approximate).
500
+ const bill = (data) => {
501
+ const b = data?.billing || {};
502
+ const units = fin(b.units) ?? fin(data?.units);
503
+ if (units != null) state.billed = (state.billed ?? 0) + units;
504
+ const chars = fin(b.characters) ?? fin(data?.characters);
505
+ if (chars == null && units == null) return;
506
+ if (chars == null) state.approx = true;
507
+ state.billedChars = (state.billedChars ?? 0) + (chars ?? units * (pricing?.charsPerWord ?? CHARS_PER_WORD));
508
+ };
438
509
  const stop = (reason, message) => { if (!state.stop) state.stop = { reason, message }; };
439
510
 
440
511
  for (const f of plan.files.filter((x) => x.status === "copy")) {
@@ -453,15 +524,19 @@ export async function runBatch(opts, io = {}) {
453
524
  for (let left = state.pausedUntil - now(); left > 0; left = state.pausedUntil - now()) await wait(left);
454
525
  };
455
526
 
456
- const callOnce = async (call) => {
527
+ // One request with the run's retry rules: `send` is one /v1/refine or one
528
+ // /v1/refine/batch call. A batch endpoint the server does not have (404/405,
529
+ // an older self-hosted engine) answers { unsupported }.
530
+ const attempt = async (send, { batch = false } = {}) => {
457
531
  let rateLimited = 0;
458
532
  let transient = 0;
459
533
  for (;;) {
460
534
  await pause();
461
535
  if (state.stop) return { skipped: true };
462
536
  try {
463
- return { data: await refine(call.text, requestOptions) };
537
+ return { data: await send() };
464
538
  } catch (e) {
539
+ if (batch && (e.status === 404 || e.status === 405)) return { unsupported: true };
465
540
  if (e.status === 429) {
466
541
  rateLimited++;
467
542
  const ms = Number.isFinite(e.retryAfterMs) ? e.retryAfterMs : Math.min(60_000, 2000 * 2 ** (rateLimited - 1));
@@ -515,32 +590,97 @@ export async function runBatch(opts, io = {}) {
515
590
  }
516
591
  };
517
592
 
518
- const done = async (call) => {
519
- if (call.result.skipped) return;
520
- if (call.result.error) {
521
- state.errorsInARow++;
522
- if (state.errorsInARow >= ERRORS_IN_A_ROW_TO_STOP) stop("error", `${state.errorsInARow} calls in a row failed; the last: ${call.result.error}`);
523
- } else {
524
- state.errorsInARow = 0;
593
+ const countError = (error) => {
594
+ state.errorsInARow++;
595
+ if (state.errorsInARow >= ERRORS_IN_A_ROW_TO_STOP) stop("error", `${state.errorsInARow} calls in a row failed; the last: ${error}`);
596
+ };
597
+
598
+ const done = async (job) => {
599
+ if (job.calls.some((c) => c.result?.skipped)) return;
600
+ if (job.failed) countError(job.failed); // a whole request failed: one error, not one per passage
601
+ else {
602
+ for (const call of job.calls) {
603
+ if (call.result.error) countError(call.result.error);
604
+ else state.errorsInARow = 0;
605
+ }
525
606
  }
526
- for (const unit of call.units) {
527
- if (unit.status || !unit.calls.every((c) => c.result && !c.result.skipped)) continue;
528
- settle(unit);
529
- const file = unit.file;
530
- if (file.status === "refine" && file.units.every((u) => u.status)) await finish(file);
607
+ for (const call of job.calls) {
608
+ for (const unit of call.units) {
609
+ if (unit.status || !unit.calls.every((c) => c.result && !c.result.skipped)) continue;
610
+ settle(unit);
611
+ const file = unit.file;
612
+ if (file.status === "refine" && file.units.every((u) => u.status)) await finish(file);
613
+ }
531
614
  }
532
615
  };
533
616
 
617
+ const single = async (call) => {
618
+ const r = await attempt(() => refine(call.text, requestOptions));
619
+ if (r.data) bill(r.data);
620
+ return r;
621
+ };
622
+
623
+ /** One batch request; every item is mapped back to its call by id. */
624
+ const sendBatch = async (job) => {
625
+ const items = job.calls.map((c, k) => ({ id: String(k), text: c.text }));
626
+ const r = await attempt(() => refineBatch(items, requestOptions), { batch: true });
627
+ if (!r.data) return r;
628
+ const data = r.data;
629
+ bill(data);
630
+ const byId = new Map(data.items.map((it) => [String(it.id), it]));
631
+ const artifacts = data.quality?.encodingArtifacts || [];
632
+ job.calls.forEach((call, k) => {
633
+ const item = byId.get(String(k));
634
+ if (!item) call.result = { error: "the batch response has no result for this passage" };
635
+ else if (item.error) call.result = { error: String(item.error) };
636
+ else {
637
+ // The batch's quality block covers every item; keep the mojibake guard per item.
638
+ const own = artifacts.filter((a) => a && String(item.output).includes(a));
639
+ call.result = { data: { ...item, id: data.id || undefined, quality: { encodingArtifacts: own } } };
640
+ }
641
+ });
642
+ return { done: true };
643
+ };
644
+
645
+ const alone = (job) => job.calls.map((call) => ({ batch: false, calls: [call] }));
534
646
  const hadWork = refining().length > 0;
535
- const queue = [...calls];
647
+ const queue = [...jobs];
536
648
  const worker = async () => {
537
649
  while (queue.length && !state.stop) {
538
- const call = queue.shift();
539
- call.result = await callOnce(call);
540
- await done(call);
650
+ const job = queue.shift();
651
+ if (job.batch && state.noBatch) { queue.unshift(...alone(job)); continue; }
652
+ if (!job.batch) {
653
+ job.calls[0].result = await single(job.calls[0]);
654
+ } else {
655
+ const r = await sendBatch(job);
656
+ if (r.unsupported) {
657
+ if (!state.noBatch) {
658
+ state.noBatch = true;
659
+ log("Note: this server has no batch endpoint (/v1/refine/batch), so every passage is sent on its own "
660
+ + `(each call is billed at least ${fmt(pricing?.minChars ?? MIN_BILLABLE_CHARS)} characters).`);
661
+ }
662
+ queue.unshift(...alone(job));
663
+ fill(); // one batch became many calls: use every worker for them
664
+ continue;
665
+ }
666
+ if (!r.done) {
667
+ for (const call of job.calls) call.result = r;
668
+ if (r.error) job.failed = r.error;
669
+ }
670
+ }
671
+ await done(job);
541
672
  }
542
673
  };
543
- await Promise.all(Array.from({ length: Math.min(concurrency, Math.max(1, calls.length)) }, worker));
674
+ const running = [];
675
+ let active = 0;
676
+ function fill() {
677
+ while (active < concurrency && queue.length && !state.stop) {
678
+ active++;
679
+ running.push(worker().finally(() => { active--; }));
680
+ }
681
+ }
682
+ fill();
683
+ while (running.length) await Promise.all(running.splice(0));
544
684
 
545
685
  for (const f of plan.files) if (f.status === "refine") f.status = "unfinished";
546
686
 
@@ -576,6 +716,12 @@ export async function runBatch(opts, io = {}) {
576
716
  kept: count("kept"),
577
717
  failed: count("failed"),
578
718
  characters: calls.filter((c) => c.result && !c.result.skipped).reduce((n, c) => n + c.text.length, 0),
719
+ // What the server says it billed, summed over the responses (null: none
720
+ // said). billedCharacters is what is shown; units is the word equivalent.
721
+ billedCharacters: state.billedChars == null ? null
722
+ : state.approx ? Math.round(state.billedChars / 10) * 10 : Math.round(state.billedChars),
723
+ ...(state.approx ? { billedCharactersApprox: true } : {}),
724
+ units: state.billed,
579
725
  },
580
726
  ...(plan.skipped?.length ? { skippedPages: plan.skipped } : {}),
581
727
  files,
@@ -612,6 +758,9 @@ export async function runBatch(opts, io = {}) {
612
758
  log(`\n${state.stop ? "Partly done" : "Done"}: ${plural(t.written + t.unchanged + t.copied, "file", "files")} in ${display(plan.output, cwd)}`
613
759
  + `${t.copied ? ` (${fmt(t.copied)} copied unchanged)` : ""}.`
614
760
  + (hadWork ? ` Passages: ${fmt(t.changed)} changed, ${fmt(t.unchangedPassages)} needed no change, ${fmt(t.kept)} kept as they were, ${fmt(t.failed)} failed.` : ""));
761
+ if (t.billedCharacters != null) {
762
+ log(`Rukkað: ${t.billedCharactersApprox ? "um " : ""}${formatChars(t.billedCharacters)} (${charsEquivalent(t.billedCharacters)}).`);
763
+ }
615
764
  if (t.kept) log(`${plural(t.kept, "passage keeps", "passages keep")} the original text because a check did not pass; the report lists ${t.kept === 1 ? "it" : "them"}.`);
616
765
  if (t.overBudget) log(`${plural(t.overBudget, "file was", "files were")} left out for --max-chars.`);
617
766
  if (summary.report) log(`Report: ${display(summary.report, cwd)}`);
package/client.d.ts CHANGED
@@ -87,6 +87,10 @@ export interface RefineResult {
87
87
  warnings: string[];
88
88
  /** true when a test key made the call: no model ran, nothing was charged, output = input. */
89
89
  test?: boolean | null;
90
+ /** Characters billed (= billing.characters); absent from engines before character billing. */
91
+ characters?: number;
92
+ /** /v1 only: what the call cost and what is left (characters, chargedIsk, balanceIsk, freeCharsLeftToday, …). */
93
+ billing?: Record<string, unknown> | null;
90
94
  }
91
95
 
92
96
  export type VerifiedRefineResult = RefineResult & { meaningPreserved: true };
@@ -113,6 +117,59 @@ export class MeaningVerificationError extends AltalandiError {
113
117
  /** Refine text and return the full hosted API response. */
114
118
  export function refine(text: string, options?: RefineOptions): Promise<RefineResult>;
115
119
 
120
+ export interface BatchItem {
121
+ /** Your key for the string; defaults to its index, as a string. */
122
+ id?: string;
123
+ /** At most 2,000 characters. */
124
+ text: string;
125
+ }
126
+
127
+ export interface BatchOptions {
128
+ sourceLang?: SourceLang;
129
+ domain?: Domain;
130
+ register?: Register;
131
+ preserveMeaning?: boolean;
132
+ scoreNativeness?: boolean;
133
+ onlyImprove?: boolean;
134
+ glossary?: GlossaryTerm[];
135
+ autoTerminology?: boolean;
136
+ contribute?: boolean;
137
+ apiKey?: string;
138
+ baseUrl?: string;
139
+ timeoutMs?: number;
140
+ fetch?: FetchLike;
141
+ }
142
+
143
+ export interface BatchItemResult {
144
+ id: string;
145
+ output: string;
146
+ sourceLang: "en" | "is";
147
+ diffs: RefineDiff[];
148
+ meaningCheck: "passed" | "changed" | "reverted" | "skipped" | "unavailable";
149
+ meaningPreserved: boolean | null;
150
+ warnings: string[];
151
+ /** true: refined in one model call with other strings, then checked on its own. */
152
+ packed: boolean;
153
+ /** Set when this string could not be refined: it came back unchanged and was not billed. */
154
+ error?: string | null;
155
+ }
156
+
157
+ export interface BatchResult {
158
+ id: string;
159
+ runUrl: string | null;
160
+ items: BatchItemResult[];
161
+ /** Characters billed for the whole batch (= billing.characters); absent from engines before character billing. */
162
+ characters?: number;
163
+ /** @deprecated The word equivalent of `characters` (= billing.units). */
164
+ units: number;
165
+ billing: Record<string, unknown> | null;
166
+ quality: RefineQuality;
167
+ warnings: string[];
168
+ }
169
+
170
+ /** Refine many short strings in one call (POST /v1/refine/batch). */
171
+ export function refineBatch(items: BatchItem[], options?: BatchOptions): Promise<BatchResult>;
172
+
116
173
  /** Refine text and throw unless meaningPreserved is strict true. */
117
174
  export function refineVerified(text: string, options?: RefineOptions): Promise<VerifiedRefineResult>;
118
175
 
package/client.mjs CHANGED
@@ -14,6 +14,18 @@ const REQUEST_FIELDS = [
14
14
  "returnDiffs",
15
15
  "contribute",
16
16
  ];
17
+ // /v1/refine/batch takes fewer options: no writer/scoring tiers, polish or diffs switch.
18
+ const BATCH_REQUEST_FIELDS = [
19
+ "sourceLang",
20
+ "domain",
21
+ "register",
22
+ "preserveMeaning",
23
+ "scoreNativeness",
24
+ "onlyImprove",
25
+ "glossary",
26
+ "autoTerminology",
27
+ "contribute",
28
+ ];
17
29
 
18
30
  export class AltalandiError extends Error {
19
31
  constructor(message, { code = "ALTALANDI_ERROR", status, retryAfterMs } = {}) {
@@ -80,16 +92,10 @@ function requestBody(text, options) {
80
92
  return body;
81
93
  }
82
94
 
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
-
95
+ /** POST a JSON body to `${baseUrl}${route}` with the key, base URL, timeout and
96
+ * error handling every call shares. Returns the payload when `validShape`
97
+ * accepts it; throws an AltalandiError (with status and Retry-After) otherwise. */
98
+ async function post(route, body, options, validShape) {
93
99
  const explicitKey = options.apiKey;
94
100
  const selectedKey = explicitKey === undefined ? env("ALTALANDI_API_KEY") : explicitKey;
95
101
  if (selectedKey !== undefined && typeof selectedKey !== "string") {
@@ -129,13 +135,13 @@ export async function refine(text, options = {}) {
129
135
  try {
130
136
  let response;
131
137
  try {
132
- response = await fetchImpl(`${baseUrl}/v1/refine`, {
138
+ response = await fetchImpl(`${baseUrl}${route}`, {
133
139
  method: "POST",
134
140
  headers: {
135
141
  "content-type": "application/json",
136
142
  authorization: `Bearer ${apiKey}`,
137
143
  },
138
- body: JSON.stringify(requestBody(text, options)),
144
+ body: JSON.stringify(body),
139
145
  signal: controller.signal,
140
146
  });
141
147
  } catch {
@@ -190,7 +196,7 @@ export async function refine(text, options = {}) {
190
196
  );
191
197
  }
192
198
 
193
- if (!payload || typeof payload !== "object" || Array.isArray(payload) || typeof payload.output !== "string") {
199
+ if (!payload || typeof payload !== "object" || Array.isArray(payload) || !validShape(payload)) {
194
200
  throw new AltalandiError("Altalandi returned an invalid response shape.", {
195
201
  code: "INVALID_RESPONSE",
196
202
  status: response.status,
@@ -203,6 +209,39 @@ export async function refine(text, options = {}) {
203
209
  }
204
210
  }
205
211
 
212
+ /**
213
+ * Refine English or Icelandic text through the hosted Altalandi API.
214
+ * Client settings (`apiKey`, `baseUrl`, `timeoutMs`, `fetch`) are never sent in
215
+ * the JSON body. The full API result is returned without weakening its verdict.
216
+ */
217
+ export async function refine(text, options = {}) {
218
+ if (typeof text !== "string" || !text.trim()) {
219
+ throw new AltalandiError("Altalandi requires non-empty text.", { code: "INVALID_INPUT" });
220
+ }
221
+ return post("/v1/refine", requestBody(text, options), options, (p) => typeof p.output === "string");
222
+ }
223
+
224
+ /**
225
+ * Refine many short strings in one call (POST /v1/refine/batch): at most 500
226
+ * items of at most 2,000 characters each, 20,000 characters together. Each item
227
+ * comes back with its own output and meaning verdict; an item with `error` came
228
+ * back unchanged and was not billed. Same key, limits and errors as refine().
229
+ */
230
+ export async function refineBatch(items, options = {}) {
231
+ if (!Array.isArray(items) || !items.length
232
+ || !items.every((it) => it && typeof it.text === "string" && (it.id === undefined || typeof it.id === "string"))) {
233
+ throw new AltalandiError("Altalandi refineBatch requires a non-empty list of { id?, text } items.", {
234
+ code: "INVALID_INPUT",
235
+ });
236
+ }
237
+ const body = { items: items.map((it) => (it.id === undefined ? { text: it.text } : { id: it.id, text: it.text })) };
238
+ for (const field of BATCH_REQUEST_FIELDS) {
239
+ if (options[field] !== undefined) body[field] = options[field];
240
+ }
241
+ return post("/v1/refine/batch", body, options, (p) => Array.isArray(p.items)
242
+ && p.items.every((it) => it && typeof it === "object" && typeof it.output === "string"));
243
+ }
244
+
206
245
  /** Assert that an existing result carries a completed, positive meaning check. */
207
246
  export function assertMeaningVerified(result) {
208
247
  if (result?.meaningPreserved !== true) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altalandi",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
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
@@ -154,6 +154,64 @@ export function formatIsk(n) {
154
154
 
155
155
  const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
156
156
 
157
+ // ── characters (stafir) ─────────────────────────────────────────────────────
158
+ // Billing is in characters. Engines from before the switch send only word
159
+ // units; those are shown as characters too (units × charsPerWord, rounded to
160
+ // tens and marked "um"). The per-100-characters rate is never shown: amounts
161
+ // in krónur and friendly equivalents (an A4 page) only.
162
+
163
+ /** Characters per word, when an engine does not say (`charsPerWord`). */
164
+ export const CHARS_PER_WORD = 6.5;
165
+ /** A normal A4 page (display only). */
166
+ export const CHARS_PER_A4_PAGE = 3200;
167
+
168
+ const singular = (n) => { const k = Math.round(Math.abs(n)); return k % 10 === 1 && k % 100 !== 11; };
169
+
170
+ /** 2690 → "2.690 stafir", 21 → "21 stafur". */
171
+ export function formatChars(n) {
172
+ return `${formatInt(n)} ${singular(n) ? "stafur" : "stafir"}`;
173
+ }
174
+
175
+ /** One friendly equivalent: "um 7 A4-síður", "um 1 A4-síða", "innan við hálfa A4-síðu". */
176
+ export function charsEquivalent(n) {
177
+ const pages = Math.round(Number(n) / CHARS_PER_A4_PAGE);
178
+ if (pages < 1) return "innan við hálfa A4-síðu";
179
+ return `um ${formatInt(pages)} ${singular(pages) ? "A4-síða" : "A4-síður"}`;
180
+ }
181
+
182
+ /** Word units of an older engine as characters, rounded to tens. */
183
+ export function unitsToChars(units, charsPerWord = CHARS_PER_WORD) {
184
+ return Math.round((Number(units) * charsPerWord) / 10) * 10;
185
+ }
186
+
187
+ const perWord = (...from) => from.map((o) => num(o?.charsPerWord)).find((v) => v != null && v > 0) ?? CHARS_PER_WORD;
188
+
189
+ /** The first source with a characters field wins; else the first with word
190
+ * units, converted. → { n, approx } or null. */
191
+ function charsOf(sources, charsKey, unitsKey, cpw) {
192
+ for (const s of sources) if (num(s?.[charsKey]) != null) return { n: s[charsKey], approx: false };
193
+ for (const s of sources) if (num(s?.[unitsKey]) != null) return { n: unitsToChars(s[unitsKey], cpw), approx: true };
194
+ return null;
195
+ }
196
+
197
+ /** Today's free allowance: "ókeypis eftir í dag 9.800 af 15.000 stöfum".
198
+ * `billing` is a call's billing block, `after`/`before` the /v1/me answers
199
+ * after and before it; any may be null. "" when nothing is known. */
200
+ export function freeCharsText(billing, after = null, before = null) {
201
+ const cpw = perWord(billing, after, before);
202
+ const left = charsOf([billing, after], "freeCharsLeftToday", "freeUnitsLeftToday", cpw);
203
+ const daily = charsOf([after, before], "freeDailyChars", "freeDailyUnits", cpw);
204
+ // One "um", before the first converted number that is not 0.
205
+ const um = (c) => (c?.approx && c.n ? "um " : "");
206
+ if (left && daily) {
207
+ const [a, b] = um(left) ? ["um ", ""] : ["", um(daily)];
208
+ return `ókeypis eftir í dag ${a}${formatInt(left.n)} af ${b}${formatInt(daily.n)} ${singular(daily.n) ? "staf" : "stöfum"}`;
209
+ }
210
+ if (left) return `ókeypis eftir í dag ${um(left)}${formatChars(left.n)}`;
211
+ if (daily && daily.n > 0) return `ókeypis ${um(daily)}${formatChars(daily.n)} á dag`;
212
+ return "";
213
+ }
214
+
157
215
  /**
158
216
  * What the call cost and what is left. `data.billing` (sent by newer engines)
159
217
  * is exact; otherwise the cost is the balance difference between /v1/me
@@ -167,24 +225,21 @@ export function costLines(data, before, after) {
167
225
  if (charged == null && num(before?.balanceIsk) != null && num(after?.balanceIsk) != null) {
168
226
  charged = Math.round((before.balanceIsk - after.balanceIsk) * 100) / 100;
169
227
  }
170
- const units = num(b.units);
171
- const unitsText = units != null ? ` · ${formatInt(units)} einingar` : "";
228
+ const chars = charsOf([b, data], "characters", "units", perWord(b, data, after, before));
229
+ const charsText = chars ? ` · ${chars.approx ? "um " : ""}${formatChars(chars.n)}` : "";
172
230
  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}`);
231
+ if (charged == null) lines.push(`- Þetta kall: kostnaður óþekktur${charsText}`);
232
+ else if (charged < 0) lines.push(`- Þetta kall: ekki hægt að reikna (inneignin breyttist á meðan)${charsText}`);
175
233
  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}`);
234
+ ? `- Þetta kall: 0 kr. af inneign${charsText}`
235
+ : `- Þetta kall: 0 kr., ókeypis dagskammtur${charsText}`);
236
+ else lines.push(`- Þetta kall: ${formatIsk(charged)} af inneign${exact ? "" : " (mismunur á inneign fyrir og eftir)"}${charsText}`);
179
237
 
180
238
  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);
239
+ const free = freeCharsText(b, after, before);
183
240
  const parts = [];
184
241
  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`);
242
+ if (free) parts.push(free);
188
243
  if (parts.length) lines.push(`- Staða: ${parts.join(" · ")}`);
189
244
  else lines.push("- Staða: náðist ekki að sækja (`npx altalandi whoami`)");
190
245
  if (balance != null && balance < 50) lines.push("- Bæta við inneign: https://altalandi.is/stjornbord/inneign");
@@ -329,6 +384,8 @@ export function batchReportMarkdown(summary) {
329
384
  `| Kaflar | ${count(t.passages)}: ${count(t.changed)} breyttir, ${count(t.unchangedPassages)} þurftu ekki breytingu, `
330
385
  + `${count(t.kept)} héldu frumtextanum, ${count(t.failed)} mistókust |`,
331
386
  `| Stafir sendir | ${count(t.characters)} |`,
387
+ ...(Number.isFinite(t.billedCharacters)
388
+ ? [`| Stafir rukkaðir | ${t.billedCharactersApprox ? "um " : ""}${count(t.billedCharacters)} |`] : []),
332
389
  `| Tegund texta | ${cell(summary?.domain || "general")} |`,
333
390
  `| Málsnið | ${cell(summary?.register || "neutral")} |`,
334
391
  ];
package/skill/SKILL.md CHANGED
@@ -14,7 +14,7 @@ description: >-
14
14
 
15
15
  Altalandi rewrites English or stiff Icelandic so it reads as if a native wrote
16
16
  it, checks that the meaning held, and lists every change with its reason. Each
17
- call is paid from the user's free daily units or their credit.
17
+ call is paid from the user's free daily characters or their credit.
18
18
 
19
19
  **Follow this procedure exactly, every time.** The user must always see the same
20
20
  thing: the verdict, the text before and after, each change with its reason, what
@@ -52,7 +52,7 @@ a check is rewritten once more, which can double that.
52
52
  3. **Paste its stdout into your reply exactly as printed**, as the first thing in
53
53
  your reply. It is Markdown: a verdict line, **Fyrir**, **Eftir**,
54
54
  **Breytingar** (each change and its reason), **Kostnaður** (cost of this call,
55
- balance and free units left) and **Verkið**, the run's own page. Do not
55
+ balance and free characters left) and **Verkið**, the run's own page. Do not
56
56
  shorten, reorder, translate or reword it. The run page shows the user the
57
57
  text sent, the result and the checks, and lets them approve, reject or edit
58
58
  the result there; point them to it when they want to review the work.
@@ -70,7 +70,7 @@ a check is rewritten once more, which can double that.
70
70
  of your own.
71
71
 
72
72
  A document of several sections (more than about 3,000 characters) goes better
73
- through the folder run below: it sends each passage separately, two at a time,
73
+ through the folder run below: it refines and checks each passage on its own,
74
74
  and a passage that fails keeps its original without holding up the rest. Save
75
75
  the text as a `.md` file in an empty temporary folder and run the folder steps.
76
76
 
@@ -120,10 +120,15 @@ charged at https://altalandi.is/stjornbord/notkun. With the tools:
120
120
  - **Never hand-edit the Icelandic and present it as Altalandi's.** If you think
121
121
  something should still change, say so separately under a heading such as
122
122
  "Mín tillaga (ekki yfirfarin af Altalandi)", or ask before running it again.
123
- - **One call per coherent text** (a page section, an email, one UI string). For
124
- a few strings, run them one by one, at most 4 at a time, and paste each block.
125
- Keep keys, placeholders (`{name}`, `%s`), HTML and code out of the text you send.
126
- For a whole folder, i18n catalog or website, use the folder run below instead.
123
+ - **One call per coherent text** (a page section, an email). Keep keys,
124
+ placeholders (`{name}`, `%s`), HTML and code out of the text you send. For a
125
+ whole folder, i18n catalog or website, use the folder run below instead.
126
+ - **Many short strings** (UI strings, an i18n catalog, a list of labels or
127
+ messages): **never call Altalandi once per string.** Put them in a JSON file
128
+ (`{"key": "English text", …}`) in an empty temporary folder, or use the
129
+ project's existing catalog folder, and run the folder run below on that folder
130
+ (`--dry-run` first). It sends short strings together in batches, which is
131
+ cheaper and faster, and still checks each string on its own.
127
132
  - **Product names and coined words** the meaning check cannot recognise can make
128
133
  it refuse a rewrite (exit 3, "ekki tókst að staðfesta"). That is the safe
129
134
  result: keep the original and tell the user.
@@ -132,11 +137,13 @@ charged at https://altalandi.is/stjornbord/notkun. With the tools:
132
137
  writes `./altalandi-reports/altalandi-<time>-<id>.md`, and the block shows
133
138
  its path. Reports contain the text, so write them only when asked.
134
139
  - **Don't use `--fast`** for text people will read: it skips the meaning check.
140
+ - **Cost is in characters and krónur,** as the block and the estimate show it.
141
+ Never work out or quote a price per 100 characters.
135
142
  - If the user only wants spelling fixed and every other word kept, use the
136
143
  `proofread_icelandic` tool where it is available. The command line rewrites:
137
144
  tell the user it may rephrase, and go through the changes with them.
138
145
  - Every call from a signed-in account is kept in that account's history for
139
- 90 days, so the run page can show it; only the account and its team see it,
146
+ as long as they keep it, so the run page can show it; only the account and its team see it,
140
147
  and it can be deleted on the page or switched off in Settings. Separately,
141
148
  free use is kept, with personal data scrubbed, to improve the service; paid
142
149
  use only with consent. Add `--no-store` when the user asks that their text
@@ -152,7 +159,7 @@ check passed.
152
159
 
153
160
  1. **Estimate first:** `npx -y altalandi@latest <folder or URL> --dry-run` (add
154
161
  `--site` for a whole site). Show the user the estimate it prints: files,
155
- passages, characters, units and the price if paid from credit.
162
+ passages, the characters billed and the price if paid from credit.
156
163
  2. **Only when the user agrees,** run the same command with `--yes` in place of
157
164
  `--dry-run`, with the shell timeout set to 10 minutes or in the background.
158
165
  The result goes to `<folder>-is` unless they name a place (`--out <path>`).
@@ -169,12 +176,12 @@ Only when a call exits with 2 (`Not logged in`):
169
176
  minutes.
170
177
  2. As soon as it prints a link (`https://altalandi.is/tengja?kodi=XXXX-XXXX`) and
171
178
  a code, show both to the user. Tell them to sign in or create an account
172
- (2,000 free units a day, and 500 kr. of trial credit once they confirm their email) and
179
+ (a free daily allowance of characters, and 500 kr. of trial credit once they confirm their email) and
173
180
  click **Tengja**.
174
181
  3. When it prints `✓ Tengt sem <username>`, run step 2 of the procedure again.
175
182
 
176
183
  Never ask the user for an API key, and do not tell them to set
177
184
  `ALTALANDI_API_KEY`. `npx -y altalandi@latest whoami` shows the account, the
178
- balance and the free units at any time.
185
+ balance and the free characters left today at any time.
179
186
 
180
187
  Docs for building the API into an app: https://altalandi.is/agents.md
package/update.mjs CHANGED
@@ -140,6 +140,8 @@ export const SHIPPED_SKILLS = new Set([
140
140
  "f7de208557f519a4", // 0.4.0
141
141
  "4bd64565d251641e", // 0.5.0
142
142
  "aeb88010599539fd", // 0.6.0
143
+ "ebfc4ef17a614973", // 0.6.1
144
+ "9def55ecc6b30785", // 0.6.2
143
145
  ]);
144
146
 
145
147
  export function skillFingerprint(text) {