altalandi 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +142 -53
- package/altalandi.mjs +169 -87
- package/client.d.ts +113 -0
- package/client.mjs +208 -0
- package/package.json +55 -17
- package/skill/SKILL.md +105 -0
- package/update.mjs +271 -0
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,142 @@
|
|
|
1
|
-
# altalandi
|
|
2
|
-
|
|
3
|
-
Turn English or stiff/AI-translated Icelandic into Icelandic that reads as if a
|
|
4
|
-
native wrote it
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
## Updating
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
altalandi update # check, then ask before updating
|
|
38
|
+
altalandi update --check # only report; change nothing
|
|
39
|
+
altalandi --version
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`altalandi update` checks npm for a newer version and asks before installing
|
|
43
|
+
it. It works out how you installed the CLI (global, project dependency or `npx`)
|
|
44
|
+
and runs the matching command. If you use Claude Code, it then offers to install
|
|
45
|
+
or refresh the `/altalandi` skill that ships with each version. A skill you have
|
|
46
|
+
edited is backed up before it is replaced.
|
|
47
|
+
|
|
48
|
+
Nothing changes without your answer. Without a terminal to ask in (agents, CI,
|
|
49
|
+
pipes) it only reports what is available; pass `--yes` when a script already has
|
|
50
|
+
your consent.
|
|
51
|
+
|
|
52
|
+
Exit codes: `0` up to date or updated · `10` an update is available but was not
|
|
53
|
+
applied · `1` the check or the update failed.
|
|
54
|
+
|
|
55
|
+
## JavaScript / TypeScript client
|
|
56
|
+
|
|
57
|
+
`refineVerified` requests the meaning gate and throws unless the API returns
|
|
58
|
+
strict `meaningPreserved: true`. It reads `ALTALANDI_API_KEY` automatically:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
import { refineVerified } from "altalandi";
|
|
62
|
+
|
|
63
|
+
const result = await refineVerified("Make sure to reach out to us today.", {
|
|
64
|
+
domain: "marketing",
|
|
65
|
+
});
|
|
66
|
+
console.log(result.output);
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Use `refine` when you need to inspect an unverified result yourself, then apply
|
|
70
|
+
the same strict assertion before shipping it:
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
import { assertMeaningVerified, refine } from "altalandi";
|
|
74
|
+
|
|
75
|
+
const result = await refine("Some text", { register: "warm" });
|
|
76
|
+
assertMeaningVerified(result); // throws on both false and null
|
|
77
|
+
console.log(result.output);
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Both functions accept `apiKey` (for a secret-manager value), `baseUrl`, and
|
|
81
|
+
`timeoutMs` alongside the request options. The defaults are
|
|
82
|
+
`ALTALANDI_API_KEY`, `https://altalandi.is/api`, and 300,000 ms. API, network,
|
|
83
|
+
timeout, and verification failures throw `AltalandiError` subclasses with a
|
|
84
|
+
stable `code`; errors never include the request text or API key. TypeScript
|
|
85
|
+
declarations ship with the package. The CLI uses the same 300-second request
|
|
86
|
+
deadline, including response-body download.
|
|
87
|
+
|
|
88
|
+
## Usage
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
altalandi "Some English or Icelandic text."
|
|
92
|
+
echo "some text" | altalandi --domain marketing
|
|
93
|
+
altalandi --json "..." # full JSON response
|
|
94
|
+
altalandi --fast "..." # skip the meaning + nativeness gates
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
| flag | default | meaning |
|
|
98
|
+
|------|---------|---------|
|
|
99
|
+
| `--domain` | `general` | `general` \| `marketing` \| `data-analysis` \| `childrens-fiction` |
|
|
100
|
+
| `--register` | `neutral` | `formal` \| `neutral` \| `warm` |
|
|
101
|
+
| `--json` | off | print the full API response |
|
|
102
|
+
| `--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) |
|
|
103
|
+
|
|
104
|
+
## The meaning guarantee
|
|
105
|
+
|
|
106
|
+
Altalandi never silently changes meaning. For Icelandic input where the rewrite
|
|
107
|
+
can't be verified to preserve meaning, it returns the **original unchanged** and
|
|
108
|
+
says so on `stderr` (or `meaningPreserved:false` with `--json`). Check it before
|
|
109
|
+
trusting the output in an automated pipeline. Only `meaningPreserved:true` is a
|
|
110
|
+
verified pass; `null` means the gate was skipped or did not complete. `--fast`
|
|
111
|
+
deliberately skips the gate, so its result is unverified by design.
|
|
112
|
+
|
|
113
|
+
Without `--fast`, a `null` meaning verdict is printed as **MEANING UNVERIFIED**:
|
|
114
|
+
the check did not run or could not complete, so review the output before use.
|
|
115
|
+
With `--fast`, `null` is expected and is printed as **meaning check skipped**.
|
|
116
|
+
API warnings are always printed on `stderr`; under `--json` they remain in the
|
|
117
|
+
unchanged `warnings` array.
|
|
118
|
+
|
|
119
|
+
For automation, a successful HTTP response is not enough: without `--fast`, the
|
|
120
|
+
CLI prints the text or JSON response and then exits with status **3** when
|
|
121
|
+
`meaningPreserved` is `false` or `null`. Only strict `true` exits 0. `--fast` is
|
|
122
|
+
the explicit verification opt-out and exits 0 after a successful API response.
|
|
123
|
+
|
|
124
|
+
| exit | meaning |
|
|
125
|
+
|------|---------|
|
|
126
|
+
| `0` | verified success, or successful `--fast` request |
|
|
127
|
+
| `1` | API or network failure |
|
|
128
|
+
| `2` | missing input/key or invalid CLI usage |
|
|
129
|
+
| `3` | response printed, but meaning was false or unverified |
|
|
130
|
+
|
|
131
|
+
## Config
|
|
132
|
+
|
|
133
|
+
| env | default | purpose |
|
|
134
|
+
|-----|---------|---------|
|
|
135
|
+
| `ALTALANDI_API_KEY` | — | your key (Bearer). Mint at https://altalandi.is/portal |
|
|
136
|
+
| `ALTALANDI_BASE_URL` | `https://altalandi.is/api` | API base (override for self-hosting) |
|
|
137
|
+
|
|
138
|
+
MIT licensed. Portal keys share a daily account-level billable-word budget;
|
|
139
|
+
operator-issued keys remain per-key, and key rotation does not reset account
|
|
140
|
+
usage. The request's text and glossary fields are charged at the effective billing-tier
|
|
141
|
+
weight. Each whitespace-delimited token is at least one unit; an unbroken token
|
|
142
|
+
uses one unit per 20 characters, rounded up.
|
package/altalandi.mjs
CHANGED
|
@@ -1,87 +1,169 @@
|
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
+
|
|
16
|
+
// Subcommands and meta flags are handled before anything that needs an API key
|
|
17
|
+
// or input text. `altalandi update` checks for a newer CLI and skill and asks
|
|
18
|
+
// before changing anything.
|
|
19
|
+
let handled = false;
|
|
20
|
+
if (args[0] === "update") {
|
|
21
|
+
handled = true;
|
|
22
|
+
const { runUpdate } = await import("./update.mjs");
|
|
23
|
+
const rest = args.slice(1);
|
|
24
|
+
if (rest.includes("-h") || rest.includes("--help")) { updateHelp(); process.exit(0); }
|
|
25
|
+
// exitCode, never process.exit(): the registry request can leave a socket
|
|
26
|
+
// mid-close, and exiting then aborts Node on Windows with a libuv assertion
|
|
27
|
+
// (UV_HANDLE_CLOSING). The refine path below exits the same way for the
|
|
28
|
+
// same reason.
|
|
29
|
+
process.exitCode = await runUpdate({
|
|
30
|
+
check: rest.includes("--check"),
|
|
31
|
+
yes: rest.includes("--yes") || rest.includes("-y"),
|
|
32
|
+
skillOnly: rest.includes("--skill-only"),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (!handled && (args.includes("-v") || args.includes("--version"))) {
|
|
36
|
+
const { currentVersion } = await import("./update.mjs");
|
|
37
|
+
console.log(await currentVersion());
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function updateHelp() {
|
|
42
|
+
console.log(`altalandi update — check for a newer version and update if you agree
|
|
43
|
+
|
|
44
|
+
Usage:
|
|
45
|
+
altalandi update check, then ask before updating the CLI and the /altalandi skill
|
|
46
|
+
altalandi update --check only report; never change anything
|
|
47
|
+
altalandi update --yes update without asking (for scripts that already have consent)
|
|
48
|
+
|
|
49
|
+
Nothing changes without consent. Without a terminal to ask in (agents, CI,
|
|
50
|
+
pipes) and without --yes, it only reports what is available.
|
|
51
|
+
|
|
52
|
+
Exit codes: 0 up to date or updated · 10 an update is available but was not
|
|
53
|
+
applied · 1 the check or the update failed.`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!handled) {
|
|
57
|
+
const opts = { domain: "general", register: "neutral", json: false, fast: false };
|
|
58
|
+
const positional = [];
|
|
59
|
+
for (let i = 0; i < args.length; i++) {
|
|
60
|
+
const a = args[i];
|
|
61
|
+
if (a === "-h" || a === "--help") { help(); process.exit(0); }
|
|
62
|
+
else if (a === "--json") opts.json = true;
|
|
63
|
+
else if (a === "--fast") opts.fast = true;
|
|
64
|
+
else if (a === "--domain") opts.domain = args[++i];
|
|
65
|
+
else if (a === "--register") opts.register = args[++i];
|
|
66
|
+
else if (a === "--base-url") opts.baseUrl = args[++i];
|
|
67
|
+
else if (a === "--key") opts.key = args[++i];
|
|
68
|
+
else positional.push(a);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function help() {
|
|
72
|
+
console.log(`altalandi — native Icelandic from your terminal
|
|
73
|
+
|
|
74
|
+
Usage:
|
|
75
|
+
altalandi "text to refine"
|
|
76
|
+
echo "text" | altalandi
|
|
77
|
+
altalandi --domain marketing --register warm "text"
|
|
78
|
+
|
|
79
|
+
Commands:
|
|
80
|
+
altalandi update check for a newer version and update if you agree
|
|
81
|
+
altalandi --version print the installed version
|
|
82
|
+
|
|
83
|
+
Options:
|
|
84
|
+
--domain general | marketing | data-analysis | childrens-fiction (default general)
|
|
85
|
+
--register formal | neutral | warm (default neutral)
|
|
86
|
+
--fast no polish, no nativeness score, no meaning gate — normally one writer call
|
|
87
|
+
(structured input can still add a structure-preserving retry)
|
|
88
|
+
--json print the full JSON response
|
|
89
|
+
--base-url API base (default $ALTALANDI_BASE_URL or https://altalandi.is/api)
|
|
90
|
+
|
|
91
|
+
stdout = refined text; stderr = nativeness / meaning / warnings / diff.
|
|
92
|
+
Without --fast, false/null meaning verification exits 3 after printing the response.`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readStdin() {
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
if (process.stdin.isTTY) return resolve("");
|
|
98
|
+
let d = "";
|
|
99
|
+
process.stdin.setEncoding("utf8");
|
|
100
|
+
process.stdin.on("data", (c) => (d += c));
|
|
101
|
+
process.stdin.on("end", () => resolve(d));
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const baseUrl = (opts.baseUrl || process.env.ALTALANDI_BASE_URL || "https://altalandi.is/api").replace(/\/$/, "");
|
|
106
|
+
const key = opts.key || process.env.ALTALANDI_API_KEY || process.env.ALTALANDI_KEY;
|
|
107
|
+
|
|
108
|
+
if (opts.key) {
|
|
109
|
+
console.error("⚠ --key is deprecated because command arguments can leak through shell history/process listings; load ALTALANDI_API_KEY instead.");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const text = (positional.join(" ").trim()) || (await readStdin()).trim();
|
|
113
|
+
if (!text) { console.error("No input. Pass text as an argument or pipe via stdin. (altalandi --help)"); process.exit(2); }
|
|
114
|
+
if (!key) { console.error("No API key. Load ALTALANDI_API_KEY securely (masked setup: https://altalandi.is/portal)."); process.exit(2); }
|
|
115
|
+
|
|
116
|
+
const requestOptions = {
|
|
117
|
+
apiKey: key,
|
|
118
|
+
baseUrl,
|
|
119
|
+
domain: opts.domain,
|
|
120
|
+
register: opts.register,
|
|
121
|
+
};
|
|
122
|
+
// --fast disables every optional quality/verification pass. The writer can
|
|
123
|
+
// still retry once when it drops document structure, as documented in --help.
|
|
124
|
+
// Name every switch explicitly so a future server-default change cannot make
|
|
125
|
+
// this client silently slower.
|
|
126
|
+
if (opts.fast) {
|
|
127
|
+
requestOptions.preserveMeaning = false;
|
|
128
|
+
requestOptions.scoreNativeness = false;
|
|
129
|
+
requestOptions.onlyImprove = false;
|
|
130
|
+
requestOptions.polishPass = false;
|
|
131
|
+
requestOptions.scoring = "none";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
// refine() owns a single 300-second AbortController deadline across fetch
|
|
136
|
+
// and response-body parsing, matching the hosted package API.
|
|
137
|
+
const data = await refine(text, requestOptions);
|
|
138
|
+
const verificationFailed = !opts.fast && data.meaningPreserved !== true;
|
|
139
|
+
|
|
140
|
+
if (opts.json) {
|
|
141
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
142
|
+
if (verificationFailed) {
|
|
143
|
+
console.error("Meaning verification did not pass; JSON was printed, exiting with status 3.");
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
process.stdout.write(data.output + "\n");
|
|
147
|
+
const q = data.quality || {};
|
|
148
|
+
const meta = [];
|
|
149
|
+
if (q.nativenessScore != null) meta.push(`nativeness ${q.nativenessScore}/100`);
|
|
150
|
+
if (data.meaningPreserved === false) meta.push("⚠ MEANING DRIFT — returned the original");
|
|
151
|
+
else if (data.meaningPreserved === true) meta.push("meaning preserved");
|
|
152
|
+
else if (opts.fast) meta.push("meaning check skipped (--fast)");
|
|
153
|
+
else meta.push("⚠ MEANING UNVERIFIED — review before use");
|
|
154
|
+
if (meta.length) console.error("· " + meta.join(" · "));
|
|
155
|
+
for (const warning of Array.isArray(data.warnings) ? data.warnings : []) {
|
|
156
|
+
console.error(`⚠ ${warning}`);
|
|
157
|
+
}
|
|
158
|
+
for (const d of data.diffs || []) console.error(` − ${d.before}\n + ${d.after} (${d.reason})`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// stdout/JSON is intentionally written first so callers can inspect the
|
|
162
|
+
// server response, but guarded automation must not accept null/false as a
|
|
163
|
+
// verified success. --fast is the explicit opt-out from this contract.
|
|
164
|
+
if (verificationFailed) process.exitCode = 3;
|
|
165
|
+
} catch (e) {
|
|
166
|
+
console.error("Request failed:", e.message);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
}
|
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,55 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "altalandi",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"type": "module",
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "altalandi",
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
+
"update.mjs",
|
|
21
|
+
"skill/SKILL.md",
|
|
22
|
+
"client.mjs",
|
|
23
|
+
"client.d.ts",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test test/cli.test.mjs test/client.test.mjs test/update.test.mjs"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"icelandic",
|
|
32
|
+
"íslenska",
|
|
33
|
+
"translation",
|
|
34
|
+
"translationese",
|
|
35
|
+
"nlp",
|
|
36
|
+
"altalandi",
|
|
37
|
+
"cli",
|
|
38
|
+
"i18n",
|
|
39
|
+
"localization",
|
|
40
|
+
"language"
|
|
41
|
+
],
|
|
42
|
+
"homepage": "https://altalandi.is",
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/pbidhjorvar/ritvit.git",
|
|
46
|
+
"directory": "tools/cli"
|
|
47
|
+
},
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/pbidhjorvar/ritvit/issues"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=18"
|
|
53
|
+
},
|
|
54
|
+
"license": "MIT"
|
|
55
|
+
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: altalandi
|
|
3
|
+
description: >-
|
|
4
|
+
MANUAL COMMAND — only run when the user explicitly invokes /altalandi. Do NOT
|
|
5
|
+
trigger automatically, and do not use it proactively on Icelandic writing tasks.
|
|
6
|
+
When invoked: refine English or stiff/AI-translated Icelandic into native
|
|
7
|
+
Icelandic via the Altalandi API, with a meaning-preservation check.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Altalandi — native Icelandic, with a meaning guarantee
|
|
11
|
+
|
|
12
|
+
Altalandi is a guarded pipeline, not a single model call: a frontier LLM rewrites
|
|
13
|
+
to native register, a rule proofreader cleans up, then a validation gate checks
|
|
14
|
+
meaning (back-translation **plus** deterministic invariants — numbers, dates,
|
|
15
|
+
negation, legal force, names) and a **candidate veto** refuses any rewrite the
|
|
16
|
+
pipeline itself has flagged as broken (invented words, a quoted defect, mojibake).
|
|
17
|
+
Every change comes back as a diff with the reason.
|
|
18
|
+
|
|
19
|
+
## How to call it
|
|
20
|
+
|
|
21
|
+
When the user runs `/altalandi`, refine the target text via the API.
|
|
22
|
+
|
|
23
|
+
1. **CLI, always with `--json`** — `npx altalandi --json "<text>"`. Flags:
|
|
24
|
+
`--domain`, `--register`. Needs `ALTALANDI_API_KEY` in the env.
|
|
25
|
+
**Use `--json` from an agent.** When a rewrite is refused, the plain-text mode
|
|
26
|
+
prints the reason as a `⚠` warning on stderr, but it also prints "meaning
|
|
27
|
+
preserved" and exits 0 — easy for an agent to misread as success. The JSON
|
|
28
|
+
carries `rewriteAttempted` and `rejectionReason`, which are unambiguous.
|
|
29
|
+
Run `npx altalandi update` now and then to get the latest CLI and this skill.
|
|
30
|
+
2. **HTTP** — `POST https://altalandi.is/api/v1/refine` with
|
|
31
|
+
`Authorization: Bearer $ALTALANDI_API_KEY` and body `{ "text": "...", ... }`.
|
|
32
|
+
|
|
33
|
+
(Do NOT rely on an MCP tool here — an MCP `refine_icelandic` tool is always available
|
|
34
|
+
to the model, which defeats manual-only invocation. This command calls the CLI/HTTP
|
|
35
|
+
directly so it only runs when you ask.)
|
|
36
|
+
|
|
37
|
+
Auth: set **`ALTALANDI_API_KEY`** once in the shell env (or your project's `.env`).
|
|
38
|
+
Existing `rtv_…` keys keep working — there is no rotation. Mint or view keys at
|
|
39
|
+
https://altalandi.is/portal. Machine-readable docs: https://altalandi.is/llms-full.txt
|
|
40
|
+
and https://altalandi.is/api/openapi.json.
|
|
41
|
+
|
|
42
|
+
## Parameters
|
|
43
|
+
|
|
44
|
+
- `text` (required) — the text to refine (English or Icelandic; auto-detected).
|
|
45
|
+
- `domain` — `general` | `marketing` | `data-analysis` | `childrens-fiction`.
|
|
46
|
+
- `register` — `formal` | `neutral` | `warm`.
|
|
47
|
+
- `sourceLang` — `auto` (default) | `en` | `is`.
|
|
48
|
+
|
|
49
|
+
## The non-negotiable contract — read the outcome, not one field
|
|
50
|
+
|
|
51
|
+
An Icelandic→Icelandic call ends in one of three ways. Tell them apart before you
|
|
52
|
+
use the output:
|
|
53
|
+
|
|
54
|
+
| outcome | how to recognise it |
|
|
55
|
+
|---|---|
|
|
56
|
+
| **Rewritten and verified** | `output` differs from your text, `meaningPreserved: true`, `rejectionReason` empty |
|
|
57
|
+
| **Rewrite refused — original returned** | `rewriteAttempted: true` and `rejectionReason` set. Causes: `MEANING DRIFT`, `HARD INVARIANT LOST`, `UNVERIFIED`, or `CANDIDATE VETO` (then `candidateVetoFired: true`, `candidateVetoSignal` says why) |
|
|
58
|
+
| **Already fine — nothing to change** | `rewriteAttempted: false` |
|
|
59
|
+
|
|
60
|
+
- **`meaningPreserved` alone is not enough.** A vetoed rewrite returns your
|
|
61
|
+
original with `meaningPreserved` still `true`: the meaning check passed, but the
|
|
62
|
+
rewrite was broken in another way. Check `rewriteAttempted` and `rejectionReason`.
|
|
63
|
+
- `meaningPreserved: null` means **unverified**, not safe. For Icelandic input the
|
|
64
|
+
original comes back; for English input the translation comes back marked
|
|
65
|
+
unverified — do not present it as verified.
|
|
66
|
+
- **When the original comes back, say so.** Don't report "Altalandi improved
|
|
67
|
+
this" when it declined to.
|
|
68
|
+
- **Show the diffs.** When you apply Altalandi's output, surface what changed and
|
|
69
|
+
why, never silently replace the user's text.
|
|
70
|
+
|
|
71
|
+
## The nativeness score is a hint, not a verdict
|
|
72
|
+
|
|
73
|
+
`quality.nativenessScore` and `translationeseTells` point at likely problems, but
|
|
74
|
+
the score has been measured to be confidently wrong in both directions: it gave a
|
|
75
|
+
rewrite containing an invented word 82/100, and a perfectly fine rewrite 18/100.
|
|
76
|
+
Treat a low score as "look at the tells", never as proof of bad Icelandic — and
|
|
77
|
+
never as permission to override a refusal. A tell that **quotes** a specific phrase
|
|
78
|
+
is far more trustworthy than the number.
|
|
79
|
+
|
|
80
|
+
## When to use it
|
|
81
|
+
|
|
82
|
+
- Producing or polishing user-facing Icelandic (web copy, UI strings, emails,
|
|
83
|
+
product text, children's prose).
|
|
84
|
+
- You translated something to Icelandic and want it to not "sound translated".
|
|
85
|
+
- Many strings at once: `POST /refine/batch`.
|
|
86
|
+
|
|
87
|
+
## When NOT to use it
|
|
88
|
+
|
|
89
|
+
- Non-Icelandic output. Code, identifiers, or data that must stay literal. Quotes
|
|
90
|
+
you must preserve verbatim (it may rephrase — check the diff).
|
|
91
|
+
|
|
92
|
+
## `--fast` removes the safety net
|
|
93
|
+
|
|
94
|
+
`--fast` (`scoreNativeness:false`, `scoring:"none"`) skips the meaning gate **and**
|
|
95
|
+
leaves the candidate veto blind: with no BÍN morphology check and no nativeness
|
|
96
|
+
tells, it cannot catch an invented or wrong word. Only mojibake is still caught.
|
|
97
|
+
Use it only for throwaway text you will read yourself — never for anything shipped.
|
|
98
|
+
|
|
99
|
+
## Notes
|
|
100
|
+
|
|
101
|
+
- Usage is metered by a daily **word budget** per key (default 10,000 words/day;
|
|
102
|
+
429 when spent, resets 00:00 UTC).
|
|
103
|
+
- A full call makes several model passes and is slower; that is the price of the
|
|
104
|
+
checks above.
|
|
105
|
+
- Data is processed and discarded — not stored or trained on.
|
package/update.mjs
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// `altalandi update` — check for a newer CLI, ask before installing it, then
|
|
2
|
+
// offer to install or refresh the /altalandi skill for Claude Code.
|
|
3
|
+
//
|
|
4
|
+
// Nothing is ever changed without consent. Interactive terminals get a y/N
|
|
5
|
+
// prompt; non-interactive callers (agents, CI, pipes) are only told what is
|
|
6
|
+
// available, unless they pass --yes, which is itself the explicit consent.
|
|
7
|
+
//
|
|
8
|
+
// Zero dependencies, like the rest of the CLI.
|
|
9
|
+
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
12
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { createInterface } from "node:readline/promises";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
export const PACKAGE = "altalandi";
|
|
19
|
+
export const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE}/latest`;
|
|
20
|
+
|
|
21
|
+
// Exit codes. 10 is deliberately distinct from the refine command's 1/2/3 so a
|
|
22
|
+
// script can tell "an update exists" apart from "something failed".
|
|
23
|
+
export const EXIT_OK = 0;
|
|
24
|
+
export const EXIT_CHECK_FAILED = 1;
|
|
25
|
+
export const EXIT_UPDATE_AVAILABLE = 10;
|
|
26
|
+
|
|
27
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
export const BUNDLED_SKILL = path.join(HERE, "skill", "SKILL.md");
|
|
29
|
+
|
|
30
|
+
export async function currentVersion() {
|
|
31
|
+
const pkg = JSON.parse(await readFile(path.join(HERE, "package.json"), "utf8"));
|
|
32
|
+
return pkg.version;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Semver-ish compare: -1 if a<b, 0 if equal, 1 if a>b. A prerelease sorts
|
|
36
|
+
* before its release (0.2.0-beta < 0.2.0). */
|
|
37
|
+
export function compareVersions(a, b) {
|
|
38
|
+
const parse = (v) => {
|
|
39
|
+
const [core, pre = ""] = String(v).trim().replace(/^v/, "").split("-", 2);
|
|
40
|
+
return { nums: core.split(".").map((n) => Number.parseInt(n, 10) || 0), pre };
|
|
41
|
+
};
|
|
42
|
+
const x = parse(a);
|
|
43
|
+
const y = parse(b);
|
|
44
|
+
for (let i = 0; i < 3; i++) {
|
|
45
|
+
const d = (x.nums[i] || 0) - (y.nums[i] || 0);
|
|
46
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
47
|
+
}
|
|
48
|
+
if (x.pre === y.pre) return 0;
|
|
49
|
+
if (!x.pre) return 1;
|
|
50
|
+
if (!y.pre) return -1;
|
|
51
|
+
return x.pre < y.pre ? -1 : 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function fetchLatestVersion({ fetchImpl = globalThis.fetch, timeoutMs = 8000 } = {}) {
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetchImpl(REGISTRY_URL, {
|
|
59
|
+
signal: controller.signal,
|
|
60
|
+
headers: { accept: "application/json" },
|
|
61
|
+
});
|
|
62
|
+
if (!res.ok) throw new Error(`npm registry answered ${res.status}`);
|
|
63
|
+
const body = JSON.parse(await res.text());
|
|
64
|
+
if (!body || typeof body.version !== "string") throw new Error("npm registry returned no version");
|
|
65
|
+
return body.version;
|
|
66
|
+
} finally {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const norm = (p) => {
|
|
72
|
+
let out = path.resolve(p);
|
|
73
|
+
try { out = realpathSync(out); } catch { /* keep the resolved path */ }
|
|
74
|
+
return process.platform === "win32" ? out.toLowerCase() : out;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** How is this copy of the CLI being run? That decides what "update" means.
|
|
78
|
+
*
|
|
79
|
+
* npx — lives in npm's _npx cache; there is nothing to install, but a bare
|
|
80
|
+
* `npx altalandi` can keep reusing an old cached copy
|
|
81
|
+
* global — `npm install -g altalandi`
|
|
82
|
+
* local — a project dependency
|
|
83
|
+
* source — a checkout of the repository; npm cannot update that
|
|
84
|
+
*/
|
|
85
|
+
export function detectInstall({ scriptPath, globalRoot }) {
|
|
86
|
+
const p = norm(scriptPath);
|
|
87
|
+
const sep = path.sep === "\\" ? /[\\/]/ : /\//;
|
|
88
|
+
const parts = p.split(sep);
|
|
89
|
+
if (parts.includes("_npx")) return "npx";
|
|
90
|
+
const nm = parts.lastIndexOf("node_modules");
|
|
91
|
+
if (nm === -1) return "source";
|
|
92
|
+
if (globalRoot && p.startsWith(norm(globalRoot) + (process.platform === "win32" ? "\\" : "/"))) {
|
|
93
|
+
return "global";
|
|
94
|
+
}
|
|
95
|
+
return "local";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The exact command that updates this install. Constants only — never built
|
|
99
|
+
* from user input. */
|
|
100
|
+
export function updatePlan(kind, { projectDir } = {}) {
|
|
101
|
+
switch (kind) {
|
|
102
|
+
case "global":
|
|
103
|
+
return { cmd: "npm", args: ["install", "-g", `${PACKAGE}@latest`], show: `npm install -g ${PACKAGE}@latest` };
|
|
104
|
+
case "local":
|
|
105
|
+
return { cmd: "npm", args: ["install", `${PACKAGE}@latest`], cwd: projectDir, show: `npm install ${PACKAGE}@latest` };
|
|
106
|
+
case "npx":
|
|
107
|
+
return { cmd: "npx", args: ["--yes", `${PACKAGE}@latest`, "--version"], show: `npx ${PACKAGE}@latest` };
|
|
108
|
+
default:
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Where Claude Code looks for personal skills. */
|
|
114
|
+
export function skillTarget({ env = process.env, home = homedir() } = {}) {
|
|
115
|
+
const root = env.CLAUDE_CONFIG_DIR || path.join(home, ".claude");
|
|
116
|
+
return { root, file: path.join(root, "skills", PACKAGE, "SKILL.md") };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** missing | current | outdated. `absent` means Claude Code itself is not set
|
|
120
|
+
* up, in which case the skill is not offered at all — never create ~/.claude
|
|
121
|
+
* for someone who does not use it. */
|
|
122
|
+
export async function skillStatus({ bundled = BUNDLED_SKILL, target = skillTarget() } = {}) {
|
|
123
|
+
if (!existsSync(bundled)) return "unbundled";
|
|
124
|
+
if (!existsSync(target.root)) return "absent";
|
|
125
|
+
if (!existsSync(target.file)) return "missing";
|
|
126
|
+
const [a, b] = await Promise.all([readFile(bundled, "utf8"), readFile(target.file, "utf8")]);
|
|
127
|
+
const clean = (s) => s.replace(/\r\n/g, "\n").trim();
|
|
128
|
+
return clean(a) === clean(b) ? "current" : "outdated";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Install or refresh the skill. An existing copy is always backed up first:
|
|
132
|
+
* people edit their skills, and an update must never silently eat that. */
|
|
133
|
+
export async function writeSkill({ bundled = BUNDLED_SKILL, target = skillTarget(), now = new Date() } = {}) {
|
|
134
|
+
await mkdir(path.dirname(target.file), { recursive: true });
|
|
135
|
+
let backup = null;
|
|
136
|
+
if (existsSync(target.file)) {
|
|
137
|
+
const stamp = now.toISOString().replace(/[:.]/g, "-");
|
|
138
|
+
backup = `${target.file}.bak-${stamp}`;
|
|
139
|
+
await copyFile(target.file, backup);
|
|
140
|
+
}
|
|
141
|
+
await writeFile(target.file, await readFile(bundled, "utf8"), "utf8");
|
|
142
|
+
return { file: target.file, backup };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function run(plan) {
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
// npm and npx are .cmd shims on Windows, which need a shell. The arguments
|
|
148
|
+
// are constants from updatePlan(), so there is nothing to inject.
|
|
149
|
+
const child = spawn(plan.cmd, plan.args, {
|
|
150
|
+
cwd: plan.cwd,
|
|
151
|
+
stdio: "inherit",
|
|
152
|
+
shell: process.platform === "win32",
|
|
153
|
+
});
|
|
154
|
+
child.on("error", () => resolve(1));
|
|
155
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function npmGlobalRoot() {
|
|
160
|
+
return new Promise((resolve) => {
|
|
161
|
+
const child = spawn("npm", ["root", "-g"], { shell: process.platform === "win32" });
|
|
162
|
+
let out = "";
|
|
163
|
+
child.stdout?.setEncoding("utf8").on("data", (c) => { out += c; });
|
|
164
|
+
child.on("error", () => resolve(null));
|
|
165
|
+
child.on("close", () => resolve(out.trim() || null));
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function ask(question, { input = process.stdin, output = process.stderr } = {}) {
|
|
170
|
+
const rl = createInterface({ input, output });
|
|
171
|
+
try {
|
|
172
|
+
const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
|
|
173
|
+
return answer === "y" || answer === "yes" || answer === "j" || answer === "já";
|
|
174
|
+
} finally {
|
|
175
|
+
rl.close();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The whole command. Every side effect is injectable so it can be tested
|
|
181
|
+
* without touching npm, the network or the real home directory.
|
|
182
|
+
*/
|
|
183
|
+
export async function runUpdate(flags, io = {}) {
|
|
184
|
+
const log = io.log || ((m) => process.stderr.write(`${m}\n`));
|
|
185
|
+
const interactive = io.interactive ?? Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
186
|
+
const confirm = io.confirm || ask;
|
|
187
|
+
const execute = io.run || run;
|
|
188
|
+
const target = io.skillTarget || skillTarget();
|
|
189
|
+
const bundled = io.bundledSkill || BUNDLED_SKILL;
|
|
190
|
+
|
|
191
|
+
// --check never changes anything. Without a terminal and without --yes there
|
|
192
|
+
// is nobody to ask, so the same rule applies: report, don't act.
|
|
193
|
+
const mayAct = !flags.check && (flags.yes || interactive);
|
|
194
|
+
const consent = async (q) => (flags.yes ? true : confirm(q));
|
|
195
|
+
|
|
196
|
+
const installed = io.currentVersion || await currentVersion();
|
|
197
|
+
|
|
198
|
+
if (!flags.skillOnly) {
|
|
199
|
+
let latest;
|
|
200
|
+
try {
|
|
201
|
+
latest = io.latestVersion || await fetchLatestVersion({ fetchImpl: io.fetch });
|
|
202
|
+
} catch (e) {
|
|
203
|
+
log(`Could not check for updates: ${e.message}`);
|
|
204
|
+
return EXIT_CHECK_FAILED;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (compareVersions(installed, latest) < 0) {
|
|
208
|
+
const scriptPath = io.scriptPath || fileURLToPath(new URL("./altalandi.mjs", import.meta.url));
|
|
209
|
+
// `npm root -g` spawns npm, so only ask it when the answer matters: a copy
|
|
210
|
+
// inside some node_modules that is not npx's cache.
|
|
211
|
+
const inNodeModules = /[\\/]node_modules[\\/]/.test(scriptPath) && !/[\\/]_npx[\\/]/.test(scriptPath);
|
|
212
|
+
const globalRoot = io.globalRoot !== undefined ? io.globalRoot
|
|
213
|
+
: (inNodeModules ? await (io.npmGlobalRoot || npmGlobalRoot)() : null);
|
|
214
|
+
const kind = io.installKind || detectInstall({ scriptPath, globalRoot });
|
|
215
|
+
const plan = updatePlan(kind, { projectDir: io.projectDir || process.cwd() });
|
|
216
|
+
log(`A new version of ${PACKAGE} is available: ${installed} → ${latest}`);
|
|
217
|
+
|
|
218
|
+
if (!plan) {
|
|
219
|
+
log("This copy runs from a source checkout, so npm cannot update it. Pull the repository instead.");
|
|
220
|
+
return EXIT_UPDATE_AVAILABLE;
|
|
221
|
+
}
|
|
222
|
+
if (!mayAct) {
|
|
223
|
+
log(`Update with: ${plan.show}`);
|
|
224
|
+
return EXIT_UPDATE_AVAILABLE;
|
|
225
|
+
}
|
|
226
|
+
if (!(await consent(`Update ${PACKAGE} to ${latest} now?`))) {
|
|
227
|
+
log(`Skipped. Update later with: ${plan.show}`);
|
|
228
|
+
return EXIT_UPDATE_AVAILABLE;
|
|
229
|
+
}
|
|
230
|
+
log(`Running: ${plan.show}`);
|
|
231
|
+
const code = await execute(plan);
|
|
232
|
+
if (code !== 0) {
|
|
233
|
+
log(`Update failed (exit ${code}). Try running it yourself: ${plan.show}`);
|
|
234
|
+
return EXIT_CHECK_FAILED;
|
|
235
|
+
}
|
|
236
|
+
log(`Updated to ${latest}.`);
|
|
237
|
+
// The skill that ships with the NEW version is the one worth installing,
|
|
238
|
+
// and this process is still the old one. Hand over to the new CLI.
|
|
239
|
+
const handoff = kind === "npx"
|
|
240
|
+
? { cmd: "npx", args: ["--yes", `${PACKAGE}@latest`, "update", "--skill-only", ...(flags.yes ? ["--yes"] : [])] }
|
|
241
|
+
: { cmd: PACKAGE, args: ["update", "--skill-only", ...(flags.yes ? ["--yes"] : [])] };
|
|
242
|
+
await execute(handoff);
|
|
243
|
+
return EXIT_OK;
|
|
244
|
+
}
|
|
245
|
+
log(`${PACKAGE} ${installed} is up to date.`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const status = await skillStatus({ bundled, target });
|
|
249
|
+
if (status === "unbundled" || status === "absent") return EXIT_OK;
|
|
250
|
+
if (status === "current") {
|
|
251
|
+
log("The /altalandi skill for Claude Code is up to date.");
|
|
252
|
+
return EXIT_OK;
|
|
253
|
+
}
|
|
254
|
+
const what = status === "missing"
|
|
255
|
+
? `Install the /altalandi skill for Claude Code (${target.file})?`
|
|
256
|
+
: "Your /altalandi skill for Claude Code differs from this version. Replace it (your copy is backed up first)?";
|
|
257
|
+
if (!mayAct) {
|
|
258
|
+
log(status === "missing"
|
|
259
|
+
? "The /altalandi skill for Claude Code is not installed. Run `altalandi update` in a terminal to install it."
|
|
260
|
+
: "Your /altalandi skill for Claude Code is out of date. Run `altalandi update` in a terminal to refresh it.");
|
|
261
|
+
return flags.skillOnly ? EXIT_OK : EXIT_UPDATE_AVAILABLE;
|
|
262
|
+
}
|
|
263
|
+
if (!(await consent(what))) {
|
|
264
|
+
log("Skill left unchanged.");
|
|
265
|
+
return EXIT_OK;
|
|
266
|
+
}
|
|
267
|
+
const written = await writeSkill({ bundled, target, now: io.now });
|
|
268
|
+
log(`Skill ${status === "missing" ? "installed" : "updated"}: ${written.file}`);
|
|
269
|
+
if (written.backup) log(`Previous copy saved as: ${written.backup}`);
|
|
270
|
+
return EXIT_OK;
|
|
271
|
+
}
|