@anchrd/intel-api 0.10.0 → 0.11.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.
@@ -1,3 +1,4 @@
1
+ import { UI_LANGUAGES } from "@anchrd/intel-contract";
1
2
  import { z } from "zod";
2
3
  const UiConfig = z.strictObject({
3
4
  theme: z.string().min(1).optional(),
@@ -100,8 +101,12 @@ export function createBuild(deps) {
100
101
  }
101
102
  catalogs[language] = candidateParsed.data;
102
103
  }
103
- if (config.defaultLanguage !== "en" && !(config.defaultLanguage in catalogs)) {
104
- throw new Error(`ui.defaultLanguage ${JSON.stringify(config.defaultLanguage)} is not listed in ui.languages`);
104
+ // A built-in language needs no entry in ui.languages — its catalog is already in the UI.
105
+ // Without this exception, `ui.defaultLanguage: "de"` would force the customer to list a copy
106
+ // of de.json that rots at every UI update, and nobody maintains that copy.
107
+ const builtIn = UI_LANGUAGES;
108
+ if (!builtIn.includes(config.defaultLanguage) && !(config.defaultLanguage in catalogs)) {
109
+ throw new Error(`ui.defaultLanguage ${JSON.stringify(config.defaultLanguage)} is neither built in (${UI_LANGUAGES.join(", ")}) nor listed in ui.languages`);
105
110
  }
106
111
  const theme = config.theme
107
112
  ? `/* Generated by \`intel build\` from ${asComment(config.theme)}. */\n${await readRequired(config.theme, "ui.theme")}\n`
@@ -15,9 +15,10 @@ export const ServerDirectoryTool = "portal_list_servers";
15
15
  *
16
16
  * ⚠️ Only the shape is assumed, never the vocabulary. The portal is somebody else's product and its
17
17
  * payload is not part of any contract Intel owns, so every field is optional and the answer is
18
- * accepted from `structuredContent`, from `{ servers: [...] }` or from a JSON text block. What
19
- * makes a row usable is not that it parsed but that its identifier is confirmed against the live
20
- * tool list below.
18
+ * accepted from `structuredContent`, from `{ servers: [...] }`, from a JSON text block, or — the
19
+ * form the live portal actually uses from the prose block `proseRows` reads. What makes a row
20
+ * usable is not that it parsed but that its identifier is confirmed against the live tool list
21
+ * below.
21
22
  */
22
23
  const DirectoryRow = z.looseObject({
23
24
  id: z.string().optional(),
@@ -26,10 +27,71 @@ const DirectoryRow = z.looseObject({
26
27
  });
27
28
  const DirectoryRows = z.array(DirectoryRow);
28
29
  const DirectoryEnvelope = z.looseObject({ servers: DirectoryRows });
30
+ /** A line the portal meant as a directory entry: `- <display name> (<handle>): <state>`. */
31
+ const ProseRow = /^\s*[-*•]\s+(.+?)\s*\(([^()\s]+)\)\s*:\s*(.*)$/;
32
+ /** What claims to be an entry at all. Everything else in the block is a heading or a hint. */
33
+ const ProseBullet = /^\s*[-*•]\s/;
34
+ /**
35
+ * ⚠️ Never the ✓. A tick is decoration: a different locale, a plain-text client or a later version
36
+ * of the portal writes the same fact another way, and a reading that hangs on one code point would
37
+ * silently switch every server off. What is read is the word, and the word alone.
38
+ *
39
+ * It has to be the WHOLE state, not a word inside it, because "disabled" contains "enabled" and
40
+ * "not enabled" is built from it — a substring test would read both as switched on, and that is the
41
+ * one direction this must never get wrong. Anything else the portal might write ("aktiviert",
42
+ * "enabled (3 tools)") is therefore not offerable, and stays declared.
43
+ */
44
+ function readsAsEnabled(state) {
45
+ const words = state.toLowerCase().match(/\p{L}+/gu);
46
+ return words !== null && words.length === 1 && words[0] === "enabled";
47
+ }
48
+ /**
49
+ * The portal's prose form: a heading, one bullet per server, a closing hint. It is what the live
50
+ * portal actually answers (#233), and it is read only after every JSON reading in `rows` has failed.
51
+ *
52
+ * ⚠️ This reading hangs on somebody else's wording and can break without warning. The portal owes
53
+ * Intel no format at all, so a reworded state, a translated page or a changed layout is a normal
54
+ * event here rather than a defect. Two things keep that from becoming a quiet widening:
55
+ *
56
+ * - **A bullet that does not read as a row refuses the whole answer.** Skipping it would hand back
57
+ * a directory with one server missing, and a missing row is not a smaller list — it lets that
58
+ * server's tools fall back onto the shorter handle that encloses them, which is exactly the
59
+ * widening the split between `declared` and `servers` exists to prevent. The price is that a
60
+ * portal which one day bullets its closing hint fails loudly with the same closed
61
+ * `tool_servers_unavailable` that #233 found; that is the intended direction.
62
+ * - **Nothing read here can invent a server.** A handle no live tool confirms is never offered, so
63
+ * a misread line costs an offer, never a delegation.
64
+ */
65
+ function proseRows(answer) {
66
+ const parsed = [];
67
+ for (const block of answer.content) {
68
+ if (typeof block !== "object" || block === null || !("text" in block))
69
+ continue;
70
+ const text = block.text;
71
+ if (typeof text !== "string")
72
+ continue;
73
+ for (const line of text.split("\n")) {
74
+ if (!ProseBullet.test(line))
75
+ continue;
76
+ const [, name, handle, state] = ProseRow.exec(line) ?? [];
77
+ if (name === undefined || handle === undefined)
78
+ return null;
79
+ // `id` carries the handle and `name` the display name, but neither is believed on its own:
80
+ // both stay candidates in `toolServersFrom`, and the live tool list decides which one
81
+ // actually namespaces.
82
+ parsed.push({ id: handle, name, enabled: readsAsEnabled(state ?? "") });
83
+ }
84
+ }
85
+ return parsed.length > 0 ? parsed : null;
86
+ }
29
87
  /**
30
88
  * Every place the portal could reasonably have put its list, tried in order. An MCP tool result is
31
89
  * either structured or text, and a server that answers with `{ servers: [...] }` is as likely as
32
90
  * one that answers with a bare array.
91
+ *
92
+ * ⚠️ The prose reading comes last and is ADDITIONAL, never replacing. The portal is somebody else's
93
+ * product: it answers in prose today and may answer structured tomorrow, and a structured answer
94
+ * must keep winning on the day it arrives beside a human sentence.
33
95
  */
34
96
  function rows(answer) {
35
97
  const candidates = [answer.structuredContent];
@@ -42,8 +104,9 @@ function rows(answer) {
42
104
  candidates.push(JSON.parse(text));
43
105
  }
44
106
  catch {
45
- // A text block that is not JSON is prose, not a directory. Skip it rather than fail: the
46
- // portal may well add a human sentence beside the payload.
107
+ // A text block that is not JSON may still be the directory see `proseRows` above. It is
108
+ // not fed to the JSON readings here, and it does not make them fail either: the portal may
109
+ // well add a human sentence beside the payload.
47
110
  }
48
111
  }
49
112
  }
@@ -55,7 +118,7 @@ function rows(answer) {
55
118
  if (wrapped.success)
56
119
  return wrapped.data.servers;
57
120
  }
58
- return null;
121
+ return proseRows(answer);
59
122
  }
60
123
  /**
61
124
  * ⚠️ The rule itself — longest match against declared handles, never a cut at an underscore — is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@anchrd/gate-sdk": "^0.7.0",
46
- "@anchrd/intel-contract": "^0.7.0",
46
+ "@anchrd/intel-contract": "^0.8.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",