altalandi 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Altalandi
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Altalandi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,53 +1,121 @@
1
- # altalandi
2
-
3
- Turn English or stiff/AI-translated Icelandic into Icelandic that reads as if a
4
- native wrote it — from your terminal. Thin client over the
5
- [Altalandi API](https://altalandi.is). Zero dependencies (Node ≥ 18).
6
-
7
- ```bash
8
- export ALTALANDI_API_KEY="rtv_..." # mint one at https://altalandi.is/portal
9
- npx altalandi "Make sure to reach out to us today."
10
- # → Hafðu samband við okkur í dag.
11
- ```
12
-
13
- `stdout` is the refined text (pipe it anywhere); `stderr` carries the 0–100
14
- nativeness score, the meaning verdict, and the diff.
15
-
16
- ## Install
17
-
18
- ```bash
19
- npm i -g altalandi # then use `altalandi ...`
20
- # or run without installing: npx altalandi "..."
21
- ```
22
-
23
- ## Usage
24
-
25
- ```bash
26
- altalandi "Some English or Icelandic text."
27
- echo "some text" | altalandi --domain marketing
28
- altalandi --json "..." # full JSON response
29
- altalandi --fast "..." # skip the meaning + nativeness gates
30
- ```
31
-
32
- | flag | default | meaning |
33
- |------|---------|---------|
34
- | `--domain` | `general` | `general` \| `marketing` \| `data-analysis` \| `childrens-fiction` |
35
- | `--register` | `neutral` | `formal` \| `neutral` \| `warm` |
36
- | `--json` | off | print the full API response |
37
- | `--fast` | off | `preserveMeaning:false`, `scoreNativeness:false` (one model call) |
38
-
39
- ## The meaning guarantee
40
-
41
- Altalandi never silently changes meaning. For Icelandic input where the rewrite
42
- can't be verified to preserve meaning, it returns the **original unchanged** and
43
- says so on `stderr` (or `meaningPreserved:false` with `--json`). Check it before
44
- trusting the output in an automated pipeline.
45
-
46
- ## Config
47
-
48
- | env | default | purpose |
49
- |-----|---------|---------|
50
- | `ALTALANDI_API_KEY` | — | your key (Bearer). Mint at https://altalandi.is/portal |
51
- | `ALTALANDI_BASE_URL` | `https://altalandi.is/api` | API base (override for self-hosting) |
52
-
53
- MIT licensed. The hosted API is metered per key (calls + words/day).
1
+ # altalandi
2
+
3
+ Turn English or stiff/AI-translated Icelandic into Icelandic that reads as if a
4
+ native wrote it—from your terminal or application. This package provides the
5
+ `altalandi` CLI and a typed, zero-dependency JavaScript client for the hosted
6
+ [Altalandi API](https://altalandi.is) (Node ≥ 18).
7
+
8
+ ```bash
9
+ # Mint once at https://altalandi.is/portal, then enter it without shell history:
10
+ read -rsp "Altalandi API key: " ALTALANDI_API_KEY; echo
11
+ export ALTALANDI_API_KEY
12
+ npx altalandi "Make sure to reach out to us today."
13
+ # → Hafðu samband við okkur í dag.
14
+ ```
15
+
16
+ PowerShell 7: `$env:ALTALANDI_API_KEY = Read-Host "Altalandi API key" -MaskInput`.
17
+
18
+ `stdout` is the refined text (pipe it anywhere); `stderr` carries the 0–100
19
+ nativeness score, the meaning verdict, every API warning, and the diff.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ npm i -g altalandi # then use `altalandi ...`
25
+ # or run without installing: npx altalandi "..."
26
+ ```
27
+
28
+ For an application dependency, install locally and import the package root:
29
+
30
+ ```bash
31
+ npm i altalandi
32
+ ```
33
+
34
+ ## JavaScript / TypeScript client
35
+
36
+ `refineVerified` requests the meaning gate and throws unless the API returns
37
+ strict `meaningPreserved: true`. It reads `ALTALANDI_API_KEY` automatically:
38
+
39
+ ```js
40
+ import { refineVerified } from "altalandi";
41
+
42
+ const result = await refineVerified("Make sure to reach out to us today.", {
43
+ domain: "marketing",
44
+ });
45
+ console.log(result.output);
46
+ ```
47
+
48
+ Use `refine` when you need to inspect an unverified result yourself, then apply
49
+ the same strict assertion before shipping it:
50
+
51
+ ```js
52
+ import { assertMeaningVerified, refine } from "altalandi";
53
+
54
+ const result = await refine("Some text", { register: "warm" });
55
+ assertMeaningVerified(result); // throws on both false and null
56
+ console.log(result.output);
57
+ ```
58
+
59
+ Both functions accept `apiKey` (for a secret-manager value), `baseUrl`, and
60
+ `timeoutMs` alongside the request options. The defaults are
61
+ `ALTALANDI_API_KEY`, `https://altalandi.is/api`, and 300,000 ms. API, network,
62
+ timeout, and verification failures throw `AltalandiError` subclasses with a
63
+ stable `code`; errors never include the request text or API key. TypeScript
64
+ declarations ship with the package. The CLI uses the same 300-second request
65
+ deadline, including response-body download.
66
+
67
+ ## Usage
68
+
69
+ ```bash
70
+ altalandi "Some English or Icelandic text."
71
+ echo "some text" | altalandi --domain marketing
72
+ altalandi --json "..." # full JSON response
73
+ altalandi --fast "..." # skip the meaning + nativeness gates
74
+ ```
75
+
76
+ | flag | default | meaning |
77
+ |------|---------|---------|
78
+ | `--domain` | `general` | `general` \| `marketing` \| `data-analysis` \| `childrens-fiction` |
79
+ | `--register` | `neutral` | `formal` \| `neutral` \| `warm` |
80
+ | `--json` | off | print the full API response |
81
+ | `--fast` | off | `preserveMeaning:false`, `scoreNativeness:false`, `onlyImprove:false`, `polishPass:false`, `scoring:"none"` — normally one model call (structured input can still add a structure-preserving retry) |
82
+
83
+ ## The meaning guarantee
84
+
85
+ Altalandi never silently changes meaning. For Icelandic input where the rewrite
86
+ can't be verified to preserve meaning, it returns the **original unchanged** and
87
+ says so on `stderr` (or `meaningPreserved:false` with `--json`). Check it before
88
+ trusting the output in an automated pipeline. Only `meaningPreserved:true` is a
89
+ verified pass; `null` means the gate was skipped or did not complete. `--fast`
90
+ deliberately skips the gate, so its result is unverified by design.
91
+
92
+ Without `--fast`, a `null` meaning verdict is printed as **MEANING UNVERIFIED**:
93
+ the check did not run or could not complete, so review the output before use.
94
+ With `--fast`, `null` is expected and is printed as **meaning check skipped**.
95
+ API warnings are always printed on `stderr`; under `--json` they remain in the
96
+ unchanged `warnings` array.
97
+
98
+ For automation, a successful HTTP response is not enough: without `--fast`, the
99
+ CLI prints the text or JSON response and then exits with status **3** when
100
+ `meaningPreserved` is `false` or `null`. Only strict `true` exits 0. `--fast` is
101
+ the explicit verification opt-out and exits 0 after a successful API response.
102
+
103
+ | exit | meaning |
104
+ |------|---------|
105
+ | `0` | verified success, or successful `--fast` request |
106
+ | `1` | API or network failure |
107
+ | `2` | missing input/key or invalid CLI usage |
108
+ | `3` | response printed, but meaning was false or unverified |
109
+
110
+ ## Config
111
+
112
+ | env | default | purpose |
113
+ |-----|---------|---------|
114
+ | `ALTALANDI_API_KEY` | — | your key (Bearer). Mint at https://altalandi.is/portal |
115
+ | `ALTALANDI_BASE_URL` | `https://altalandi.is/api` | API base (override for self-hosting) |
116
+
117
+ MIT licensed. Portal keys share a daily account-level billable-word budget;
118
+ operator-issued keys remain per-key, and key rotation does not reset account
119
+ usage. The request's text and glossary fields are charged at the effective billing-tier
120
+ weight. Each whitespace-delimited token is at least one unit; an unbroken token
121
+ uses one unit per 20 characters, rounded up.
package/altalandi.mjs CHANGED
@@ -1,87 +1,122 @@
1
- #!/usr/bin/env node
2
- // altalandi — turn English or stiff/AI Icelandic into native Icelandic from
3
- // your terminal. Zero dependencies (Node >= 18 has global fetch).
4
- //
5
- // export ALTALANDI_API_KEY=... # get one at https://altalandi.is
6
- // altalandi "Make sure to reach out to us today."
7
- // echo "some text" | altalandi --domain marketing
8
- //
9
- // stdout = the refined text (so you can pipe it); stderr = nativeness score,
10
- // meaning verdict, and the diff.
11
-
12
- const args = process.argv.slice(2);
13
- const opts = { domain: "general", register: "neutral", json: false, fast: false };
14
- const positional = [];
15
- for (let i = 0; i < args.length; i++) {
16
- const a = args[i];
17
- if (a === "-h" || a === "--help") { help(); process.exit(0); }
18
- else if (a === "--json") opts.json = true;
19
- else if (a === "--fast") opts.fast = true;
20
- else if (a === "--domain") opts.domain = args[++i];
21
- else if (a === "--register") opts.register = args[++i];
22
- else if (a === "--base-url") opts.baseUrl = args[++i];
23
- else if (a === "--key") opts.key = args[++i];
24
- else positional.push(a);
25
- }
26
-
27
- function help() {
28
- console.log(`altalandi — native Icelandic from your terminal
29
-
30
- Usage:
31
- altalandi "text to refine"
32
- echo "text" | altalandi
33
- altalandi --domain marketing --register warm "text"
34
-
35
- Options:
36
- --domain general | marketing | data-analysis | childrens-fiction (default general)
37
- --register formal | neutral | warm (default neutral)
38
- --fast skip the meaning gate + nativeness score (faster, cheaper)
39
- --json print the full JSON response
40
- --base-url API base (default $ALTALANDI_BASE_URL or https://altalandi.is/api)
41
- --key API key (default $ALTALANDI_API_KEY)
42
-
43
- stdout = refined text; stderr = nativeness / meaning / diff.`);
44
- }
45
-
46
- function readStdin() {
47
- return new Promise((resolve) => {
48
- if (process.stdin.isTTY) return resolve("");
49
- let d = "";
50
- process.stdin.setEncoding("utf8");
51
- process.stdin.on("data", (c) => (d += c));
52
- process.stdin.on("end", () => resolve(d));
53
- });
54
- }
55
-
56
- const baseUrl = (opts.baseUrl || process.env.ALTALANDI_BASE_URL || "https://altalandi.is/api").replace(/\/$/, "");
57
- const key = opts.key || process.env.ALTALANDI_API_KEY || process.env.ALTALANDI_KEY;
58
-
59
- const text = (positional.join(" ").trim()) || (await readStdin()).trim();
60
- if (!text) { console.error("No input. Pass text as an argument or pipe via stdin. (altalandi --help)"); process.exit(2); }
61
- if (!key) { console.error("No API key. Set ALTALANDI_API_KEY or pass --key. Get one at https://altalandi.is"); process.exit(2); }
62
-
63
- const body = { text, domain: opts.domain, register: opts.register };
64
- if (opts.fast) { body.preserveMeaning = false; body.scoreNativeness = false; }
65
-
66
- try {
67
- const res = await fetch(`${baseUrl}/v1/refine`, {
68
- method: "POST",
69
- headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
70
- body: JSON.stringify(body),
71
- });
72
- const data = await res.json().catch(() => ({}));
73
- if (!res.ok) { console.error(`Error ${res.status}: ${data.detail || res.statusText}`); process.exit(1); }
74
- if (opts.json) { process.stdout.write(JSON.stringify(data, null, 2) + "\n"); process.exit(0); }
75
-
76
- process.stdout.write(data.output + "\n");
77
- const q = data.quality || {};
78
- const meta = [];
79
- if (q.nativenessScore != null) meta.push(`nativeness ${q.nativenessScore}/100`);
80
- if (data.meaningPreserved === false) meta.push("⚠ MEANING DRIFT — returned the original");
81
- else if (data.meaningPreserved === true) meta.push("meaning preserved");
82
- if (meta.length) console.error("· " + meta.join(" · "));
83
- for (const d of data.diffs || []) console.error(` − ${d.before}\n + ${d.after} (${d.reason})`);
84
- } catch (e) {
85
- console.error("Request failed:", e.message);
86
- process.exit(1);
87
- }
1
+ #!/usr/bin/env node
2
+ // altalandi — turn English or stiff/AI Icelandic into native Icelandic from
3
+ // your terminal. Zero dependencies (Node >= 18 has global fetch).
4
+ //
5
+ // export ALTALANDI_API_KEY=... # get one at https://altalandi.is
6
+ // altalandi "Make sure to reach out to us today."
7
+ // echo "some text" | altalandi --domain marketing
8
+ //
9
+ // stdout = the refined text (so you can pipe it); stderr = nativeness score,
10
+ // meaning verdict, API warnings, and the diff.
11
+
12
+ import { refine } from "./client.mjs";
13
+
14
+ const args = process.argv.slice(2);
15
+ const opts = { domain: "general", register: "neutral", json: false, fast: false };
16
+ const positional = [];
17
+ for (let i = 0; i < args.length; i++) {
18
+ const a = args[i];
19
+ if (a === "-h" || a === "--help") { help(); process.exit(0); }
20
+ else if (a === "--json") opts.json = true;
21
+ else if (a === "--fast") opts.fast = true;
22
+ else if (a === "--domain") opts.domain = args[++i];
23
+ else if (a === "--register") opts.register = args[++i];
24
+ else if (a === "--base-url") opts.baseUrl = args[++i];
25
+ else if (a === "--key") opts.key = args[++i];
26
+ else positional.push(a);
27
+ }
28
+
29
+ function help() {
30
+ console.log(`altalandi — native Icelandic from your terminal
31
+
32
+ Usage:
33
+ altalandi "text to refine"
34
+ echo "text" | altalandi
35
+ altalandi --domain marketing --register warm "text"
36
+
37
+ Options:
38
+ --domain general | marketing | data-analysis | childrens-fiction (default general)
39
+ --register formal | neutral | warm (default neutral)
40
+ --fast no polish, no nativeness score, no meaning gate — normally one writer call
41
+ (structured input can still add a structure-preserving retry)
42
+ --json print the full JSON response
43
+ --base-url API base (default $ALTALANDI_BASE_URL or https://altalandi.is/api)
44
+
45
+ stdout = refined text; stderr = nativeness / meaning / warnings / diff.
46
+ Without --fast, false/null meaning verification exits 3 after printing the response.`);
47
+ }
48
+
49
+ function readStdin() {
50
+ return new Promise((resolve) => {
51
+ if (process.stdin.isTTY) return resolve("");
52
+ let d = "";
53
+ process.stdin.setEncoding("utf8");
54
+ process.stdin.on("data", (c) => (d += c));
55
+ process.stdin.on("end", () => resolve(d));
56
+ });
57
+ }
58
+
59
+ const baseUrl = (opts.baseUrl || process.env.ALTALANDI_BASE_URL || "https://altalandi.is/api").replace(/\/$/, "");
60
+ const key = opts.key || process.env.ALTALANDI_API_KEY || process.env.ALTALANDI_KEY;
61
+
62
+ if (opts.key) {
63
+ console.error("⚠ --key is deprecated because command arguments can leak through shell history/process listings; load ALTALANDI_API_KEY instead.");
64
+ }
65
+
66
+ const text = (positional.join(" ").trim()) || (await readStdin()).trim();
67
+ if (!text) { console.error("No input. Pass text as an argument or pipe via stdin. (altalandi --help)"); process.exit(2); }
68
+ if (!key) { console.error("No API key. Load ALTALANDI_API_KEY securely (masked setup: https://altalandi.is/portal)."); process.exit(2); }
69
+
70
+ const requestOptions = {
71
+ apiKey: key,
72
+ baseUrl,
73
+ domain: opts.domain,
74
+ register: opts.register,
75
+ };
76
+ // --fast disables every optional quality/verification pass. The writer can
77
+ // still retry once when it drops document structure, as documented in --help.
78
+ // Name every switch explicitly so a future server-default change cannot make
79
+ // this client silently slower.
80
+ if (opts.fast) {
81
+ requestOptions.preserveMeaning = false;
82
+ requestOptions.scoreNativeness = false;
83
+ requestOptions.onlyImprove = false;
84
+ requestOptions.polishPass = false;
85
+ requestOptions.scoring = "none";
86
+ }
87
+
88
+ try {
89
+ // refine() owns a single 300-second AbortController deadline across fetch
90
+ // and response-body parsing, matching the hosted package API.
91
+ const data = await refine(text, requestOptions);
92
+ const verificationFailed = !opts.fast && data.meaningPreserved !== true;
93
+
94
+ if (opts.json) {
95
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
96
+ if (verificationFailed) {
97
+ console.error("Meaning verification did not pass; JSON was printed, exiting with status 3.");
98
+ }
99
+ } else {
100
+ process.stdout.write(data.output + "\n");
101
+ const q = data.quality || {};
102
+ const meta = [];
103
+ if (q.nativenessScore != null) meta.push(`nativeness ${q.nativenessScore}/100`);
104
+ if (data.meaningPreserved === false) meta.push("⚠ MEANING DRIFT — returned the original");
105
+ else if (data.meaningPreserved === true) meta.push("meaning preserved");
106
+ else if (opts.fast) meta.push("meaning check skipped (--fast)");
107
+ else meta.push("⚠ MEANING UNVERIFIED — review before use");
108
+ if (meta.length) console.error("· " + meta.join(" · "));
109
+ for (const warning of Array.isArray(data.warnings) ? data.warnings : []) {
110
+ console.error(`⚠ ${warning}`);
111
+ }
112
+ for (const d of data.diffs || []) console.error(` − ${d.before}\n + ${d.after} (${d.reason})`);
113
+ }
114
+
115
+ // stdout/JSON is intentionally written first so callers can inspect the
116
+ // server response, but guarded automation must not accept null/false as a
117
+ // verified success. --fast is the explicit opt-out from this contract.
118
+ if (verificationFailed) process.exitCode = 3;
119
+ } catch (e) {
120
+ console.error("Request failed:", e.message);
121
+ process.exit(1);
122
+ }
package/client.d.ts ADDED
@@ -0,0 +1,113 @@
1
+ export type SourceLang = "auto" | "en" | "is";
2
+ export type Domain = "general" | "marketing" | "data-analysis" | "childrens-fiction";
3
+ export type Register = "formal" | "neutral" | "warm";
4
+ export type WriterTier = "standard" | "max";
5
+ export type ScoringTier = "none" | "fast" | "rigorous";
6
+
7
+ export interface GlossaryTerm {
8
+ en: string;
9
+ is: string;
10
+ note?: string;
11
+ }
12
+
13
+ export interface FetchResponseLike {
14
+ ok: boolean;
15
+ status: number;
16
+ statusText?: string;
17
+ text(): Promise<string>;
18
+ }
19
+
20
+ export type FetchLike = (
21
+ url: string,
22
+ init: {
23
+ method: string;
24
+ headers: Record<string, string>;
25
+ body: string;
26
+ signal: any;
27
+ },
28
+ ) => Promise<FetchResponseLike>;
29
+
30
+ export interface RefineOptions {
31
+ sourceLang?: SourceLang;
32
+ domain?: Domain;
33
+ register?: Register;
34
+ preserveMeaning?: boolean;
35
+ scoreNativeness?: boolean;
36
+ /** IS-refine: keep the original unless improvement is verified. */
37
+ onlyImprove?: boolean;
38
+ /** Optional EN→IS monolingual polish pass; false by default. */
39
+ polishPass?: boolean;
40
+ writer?: WriterTier;
41
+ scoring?: ScoringTier;
42
+ glossary?: GlossaryTerm[];
43
+ returnDiffs?: boolean;
44
+ /** Defaults to ALTALANDI_API_KEY. Never put a key in source control. */
45
+ apiKey?: string;
46
+ /** Defaults to https://altalandi.is/api. */
47
+ baseUrl?: string;
48
+ /** Per-request timeout in milliseconds; defaults to 300000. */
49
+ timeoutMs?: number;
50
+ /** Optional fetch-compatible implementation, useful for controlled runtimes and tests. */
51
+ fetch?: FetchLike;
52
+ }
53
+
54
+ export interface RefineDiff {
55
+ before: string;
56
+ after: string;
57
+ layer: "L1" | "L2" | "L3";
58
+ reason: string;
59
+ }
60
+
61
+ export interface RefineQuality {
62
+ nativenessScore: number | null;
63
+ translationeseTells: string[];
64
+ iceBertScore: number | null;
65
+ morphologyOk: boolean | null;
66
+ calqueFlags: string[];
67
+ englishRemnants: string[];
68
+ encodingArtifacts: string[];
69
+ }
70
+
71
+ export interface RefineResult {
72
+ id: string;
73
+ output: string;
74
+ sourceLang: "en" | "is";
75
+ diffs: RefineDiff[];
76
+ quality: RefineQuality;
77
+ /** Only strict true is a completed verification; null means skipped/unavailable. */
78
+ meaningPreserved: boolean | null;
79
+ meaningNotes: string;
80
+ modelUsed: string;
81
+ tier?: { writer: WriterTier; scoring: ScoringTier };
82
+ billedTier?: { writer: WriterTier; scoring: ScoringTier };
83
+ warnings: string[];
84
+ }
85
+
86
+ export type VerifiedRefineResult = RefineResult & { meaningPreserved: true };
87
+
88
+ export interface AltalandiErrorOptions {
89
+ code?: string;
90
+ status?: number;
91
+ }
92
+
93
+ export class AltalandiError extends Error {
94
+ readonly code: string;
95
+ readonly status?: number;
96
+ constructor(message: string, options?: AltalandiErrorOptions);
97
+ }
98
+
99
+ export class MeaningVerificationError extends AltalandiError {
100
+ readonly verdict: false | null;
101
+ constructor(verdict: boolean | null | undefined);
102
+ }
103
+
104
+ /** Refine text and return the full hosted API response. */
105
+ export function refine(text: string, options?: RefineOptions): Promise<RefineResult>;
106
+
107
+ /** Refine text and throw unless meaningPreserved is strict true. */
108
+ export function refineVerified(text: string, options?: RefineOptions): Promise<VerifiedRefineResult>;
109
+
110
+ /** Narrow an existing result to one with a verified meaning verdict. */
111
+ export function assertMeaningVerified(
112
+ result: RefineResult,
113
+ ): asserts result is VerifiedRefineResult;
package/client.mjs ADDED
@@ -0,0 +1,208 @@
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
+ ];
16
+
17
+ export class AltalandiError extends Error {
18
+ constructor(message, { code = "ALTALANDI_ERROR", status } = {}) {
19
+ super(message);
20
+ this.name = "AltalandiError";
21
+ this.code = code;
22
+ if (Number.isInteger(status)) this.status = status;
23
+ }
24
+ }
25
+
26
+ export class MeaningVerificationError extends AltalandiError {
27
+ constructor(verdict) {
28
+ const changed = verdict === false;
29
+ super(
30
+ changed
31
+ ? "Altalandi could not preserve the input meaning."
32
+ : "Altalandi did not return a verified meaning verdict.",
33
+ { code: changed ? "MEANING_NOT_PRESERVED" : "MEANING_UNVERIFIED" },
34
+ );
35
+ this.name = "MeaningVerificationError";
36
+ this.verdict = verdict === false ? false : null;
37
+ }
38
+ }
39
+
40
+ function env(name) {
41
+ if (typeof process === "undefined" || !process.env) return undefined;
42
+ return process.env[name];
43
+ }
44
+
45
+ function cleanText(value, secret = "") {
46
+ if (typeof value !== "string") return "";
47
+ let cleaned = value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
48
+ if (secret) cleaned = cleaned.split(secret).join("[redacted]");
49
+ return cleaned.slice(0, 300);
50
+ }
51
+
52
+ function errorDetail(payload) {
53
+ const detail = payload?.detail;
54
+ if (typeof detail === "string") return detail;
55
+ if (Array.isArray(detail)) {
56
+ return detail
57
+ .map((entry) => (typeof entry?.msg === "string" ? entry.msg : ""))
58
+ .filter(Boolean)
59
+ .join("; ");
60
+ }
61
+ return "";
62
+ }
63
+
64
+ function requestBody(text, options) {
65
+ const body = { text };
66
+ for (const field of REQUEST_FIELDS) {
67
+ if (options[field] !== undefined) body[field] = options[field];
68
+ }
69
+ return body;
70
+ }
71
+
72
+ /**
73
+ * Refine English or Icelandic text through the hosted Altalandi API.
74
+ * Client settings (`apiKey`, `baseUrl`, `timeoutMs`, `fetch`) are never sent in
75
+ * the JSON body. The full API result is returned without weakening its verdict.
76
+ */
77
+ export async function refine(text, options = {}) {
78
+ if (typeof text !== "string" || !text.trim()) {
79
+ throw new AltalandiError("Altalandi requires non-empty text.", { code: "INVALID_INPUT" });
80
+ }
81
+
82
+ const explicitKey = options.apiKey;
83
+ const selectedKey = explicitKey === undefined ? env("ALTALANDI_API_KEY") : explicitKey;
84
+ if (selectedKey !== undefined && typeof selectedKey !== "string") {
85
+ throw new AltalandiError("Altalandi apiKey must be a string.", { code: "INVALID_CONFIG" });
86
+ }
87
+ const apiKey = selectedKey?.trim();
88
+ if (!apiKey) {
89
+ throw new AltalandiError(
90
+ "No Altalandi API key. Set ALTALANDI_API_KEY or pass apiKey explicitly.",
91
+ { code: "MISSING_API_KEY" },
92
+ );
93
+ }
94
+
95
+ const selectedBaseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
96
+ if (typeof selectedBaseUrl !== "string") {
97
+ throw new AltalandiError("Altalandi baseUrl must be a string.", { code: "INVALID_CONFIG" });
98
+ }
99
+ const baseUrl = selectedBaseUrl.trim().replace(/\/+$/, "");
100
+ if (!baseUrl) {
101
+ throw new AltalandiError("Altalandi baseUrl must not be empty.", { code: "INVALID_CONFIG" });
102
+ }
103
+
104
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
105
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
106
+ throw new AltalandiError("Altalandi timeoutMs must be a positive number.", {
107
+ code: "INVALID_CONFIG",
108
+ });
109
+ }
110
+
111
+ const fetchImpl = options.fetch ?? globalThis.fetch;
112
+ if (typeof fetchImpl !== "function") {
113
+ throw new AltalandiError("This runtime does not provide fetch.", { code: "INVALID_CONFIG" });
114
+ }
115
+
116
+ const controller = new AbortController();
117
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
118
+ try {
119
+ let response;
120
+ try {
121
+ response = await fetchImpl(`${baseUrl}/v1/refine`, {
122
+ method: "POST",
123
+ headers: {
124
+ "content-type": "application/json",
125
+ authorization: `Bearer ${apiKey}`,
126
+ },
127
+ body: JSON.stringify(requestBody(text, options)),
128
+ signal: controller.signal,
129
+ });
130
+ } catch {
131
+ if (controller.signal.aborted) {
132
+ throw new AltalandiError(`Altalandi timed out after ${timeoutMs} ms.`, {
133
+ code: "TIMEOUT",
134
+ });
135
+ }
136
+ throw new AltalandiError("Altalandi could not reach the API.", { code: "NETWORK_ERROR" });
137
+ }
138
+
139
+ let raw = "";
140
+ try {
141
+ raw = await response.text();
142
+ } catch {
143
+ if (controller.signal.aborted) {
144
+ throw new AltalandiError(`Altalandi timed out after ${timeoutMs} ms.`, {
145
+ code: "TIMEOUT",
146
+ });
147
+ }
148
+ if (response.ok) {
149
+ throw new AltalandiError("Altalandi could not read the API response.", {
150
+ code: "INVALID_RESPONSE",
151
+ status: response.status,
152
+ });
153
+ }
154
+ // The HTTP status remains useful when an error response has no readable body.
155
+ }
156
+
157
+ let payload;
158
+ if (raw) {
159
+ try {
160
+ payload = JSON.parse(raw);
161
+ } catch {
162
+ if (response.ok) {
163
+ throw new AltalandiError("Altalandi returned an invalid JSON response.", {
164
+ code: "INVALID_RESPONSE",
165
+ status: response.status,
166
+ });
167
+ }
168
+ }
169
+ }
170
+
171
+ if (!response.ok) {
172
+ const status = Number.isInteger(response.status) ? response.status : 0;
173
+ const statusText = cleanText(response.statusText);
174
+ const detail = cleanText(errorDetail(payload), apiKey);
175
+ const suffix = detail || statusText;
176
+ throw new AltalandiError(
177
+ `Altalandi API request failed (${status || "unknown status"})${suffix ? `: ${suffix}` : "."}`,
178
+ { code: "API_ERROR", status },
179
+ );
180
+ }
181
+
182
+ if (!payload || typeof payload !== "object" || Array.isArray(payload) || typeof payload.output !== "string") {
183
+ throw new AltalandiError("Altalandi returned an invalid response shape.", {
184
+ code: "INVALID_RESPONSE",
185
+ status: response.status,
186
+ });
187
+ }
188
+
189
+ return payload;
190
+ } finally {
191
+ clearTimeout(timeout);
192
+ }
193
+ }
194
+
195
+ /** Assert that an existing result carries a completed, positive meaning check. */
196
+ export function assertMeaningVerified(result) {
197
+ if (result?.meaningPreserved !== true) {
198
+ throw new MeaningVerificationError(result?.meaningPreserved);
199
+ }
200
+ return result;
201
+ }
202
+
203
+ /** Request meaning verification and reject unless the API returns strict true. */
204
+ export async function refineVerified(text, options = {}) {
205
+ const result = await refine(text, { ...options, preserveMeaning: true });
206
+ assertMeaningVerified(result);
207
+ return result;
208
+ }
package/package.json CHANGED
@@ -1,17 +1,53 @@
1
- {
2
- "name": "altalandi",
3
- "version": "0.1.0",
4
- "description": "Turn English or stiff/AI-translated Icelandic into native-sounding Icelandic from your terminal — via the Altalandi API, with a meaning-preservation check.",
5
- "type": "module",
6
- "bin": { "altalandi": "./altalandi.mjs" },
7
- "files": ["altalandi.mjs", "README.md", "LICENSE"],
8
- "keywords": [
9
- "icelandic", "íslenska", "translation", "translationese", "nlp",
10
- "altalandi", "cli", "i18n", "localization", "language"
11
- ],
12
- "homepage": "https://altalandi.is",
13
- "repository": { "type": "git", "url": "git+https://github.com/pbidhjorvar/ritvit.git", "directory": "tools/cli" },
14
- "bugs": { "url": "https://github.com/pbidhjorvar/ritvit/issues" },
15
- "engines": { "node": ">=18" },
16
- "license": "MIT"
17
- }
1
+ {
2
+ "name": "altalandi",
3
+ "version": "0.1.1",
4
+ "description": "Hosted Altalandi API client and CLI for native-sounding Icelandic with meaning verification.",
5
+ "type": "module",
6
+ "main": "./client.mjs",
7
+ "types": "./client.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./client.d.ts",
11
+ "import": "./client.mjs",
12
+ "default": "./client.mjs"
13
+ }
14
+ },
15
+ "bin": {
16
+ "altalandi": "altalandi.mjs"
17
+ },
18
+ "files": [
19
+ "altalandi.mjs",
20
+ "client.mjs",
21
+ "client.d.ts",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "test": "node --test test/cli.test.mjs test/client.test.mjs"
27
+ },
28
+ "keywords": [
29
+ "icelandic",
30
+ "íslenska",
31
+ "translation",
32
+ "translationese",
33
+ "nlp",
34
+ "altalandi",
35
+ "cli",
36
+ "i18n",
37
+ "localization",
38
+ "language"
39
+ ],
40
+ "homepage": "https://altalandi.is",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/pbidhjorvar/ritvit.git",
44
+ "directory": "tools/cli"
45
+ },
46
+ "bugs": {
47
+ "url": "https://github.com/pbidhjorvar/ritvit/issues"
48
+ },
49
+ "engines": {
50
+ "node": ">=18"
51
+ },
52
+ "license": "MIT"
53
+ }