@anchrd/intel-api 0.13.0 → 0.15.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.
Files changed (57) hide show
  1. package/dist/adapters/cloudflare/cloudflare.js +1 -68
  2. package/dist/adapters/cloudflare/cloudflare.types.d.ts +0 -39
  3. package/dist/adapters/db/db-flows.js +1 -1
  4. package/dist/adapters/db/db-grants.js +1 -1
  5. package/dist/adapters/db/db-indexing.js +79 -0
  6. package/dist/adapters/db/db.js +81 -139
  7. package/dist/adapters/semantic-index/semantic-index.js +97 -17
  8. package/dist/adapters/semantic-index/semantic-index.types.d.ts +20 -1
  9. package/dist/bundle/bundle.js +42 -134
  10. package/dist/cli/cli.js +3 -9
  11. package/dist/http/http.js +5 -206
  12. package/dist/http/http.types.d.ts +0 -8
  13. package/dist/indexing/indexing.js +133 -55
  14. package/dist/indexing/indexing.types.d.ts +1 -0
  15. package/dist/intel/intel.js +4 -9
  16. package/dist/intel/intel.types.d.ts +0 -6
  17. package/dist/mcp/mcp.js +33 -308
  18. package/dist/mcp/mcp.types.d.ts +2 -7
  19. package/dist/nodes/document-links/document-links.d.ts +6 -8
  20. package/dist/nodes/document-links/document-links.js +8 -31
  21. package/dist/nodes/nodes.js +92 -826
  22. package/dist/nodes/nodes.types.d.ts +57 -158
  23. package/dist/tools/tools.js +37 -148
  24. package/dist/tools/tools.types.d.ts +0 -21
  25. package/migrations/0009_no_context_policy.sql +15 -0
  26. package/migrations/0017_a_vector_per_card.sql +38 -0
  27. package/migrations/0018_no_context_policy_at_last.sql +97 -0
  28. package/migrations/0019_one_name_for_the_grants.sql +52 -0
  29. package/package.json +2 -2
  30. package/dist/adapters/cloudflare-api/cloudflare-api.d.ts +0 -22
  31. package/dist/adapters/cloudflare-api/cloudflare-api.js +0 -214
  32. package/dist/adapters/cloudflare-api/cloudflare-api.types.d.ts +0 -64
  33. package/dist/adapters/cloudflare-api/cloudflare-api.types.js +0 -1
  34. package/dist/adapters/gate-applications/gate-applications.d.ts +0 -23
  35. package/dist/adapters/gate-applications/gate-applications.js +0 -88
  36. package/dist/adapters/tool-delegation/tool-delegation.d.ts +0 -22
  37. package/dist/adapters/tool-delegation/tool-delegation.js +0 -90
  38. package/dist/agent-costs/agent-costs.d.ts +0 -16
  39. package/dist/agent-costs/agent-costs.js +0 -105
  40. package/dist/agent-costs/agent-costs.types.d.ts +0 -30
  41. package/dist/agent-costs/agent-costs.types.js +0 -1
  42. package/dist/agent-runtime/agent-runtime.d.ts +0 -16
  43. package/dist/agent-runtime/agent-runtime.js +0 -150
  44. package/dist/agent-runtime/agent-runtime.types.d.ts +0 -122
  45. package/dist/agent-runtime/agent-runtime.types.js +0 -1
  46. package/dist/model-catalog/model-catalog.d.ts +0 -2
  47. package/dist/model-catalog/model-catalog.js +0 -99
  48. package/dist/model-catalog/model-catalog.types.d.ts +0 -15
  49. package/dist/model-catalog/model-catalog.types.js +0 -1
  50. package/dist/nodes/board/board.d.ts +0 -59
  51. package/dist/nodes/board/board.js +0 -528
  52. package/dist/nodes/board/board.types.d.ts +0 -31
  53. package/dist/nodes/board/board.types.js +0 -1
  54. package/migrations/0013_agents_in_the_tree.sql +0 -76
  55. package/migrations/0014_agent_applications.sql +0 -25
  56. package/migrations/0015_tools_delegated_from_a_connection.sql +0 -15
  57. package/migrations/0016_boards_in_the_tree.sql +0 -80
@@ -1,5 +1,13 @@
1
1
  -- #76. `context_policy` leaves the contract, the UI and every MCP answer. The COLUMN stays.
2
2
  --
3
+ -- ⚠️ HISTORY, and no longer an explanation of what is possible. The column was finally dropped by
4
+ -- `0018_no_context_policy_at_last.sql` (#86), through an ordinary migration file — the very thing
5
+ -- the conclusion at the bottom of this file says cannot be done. What changed is not D1 but the
6
+ -- recipe: `0005`, `0013` and `0016` worked out that the new table has to be created under the FINAL
7
+ -- name instead of renamed into place, and that `node_links` has to be carried out of the way
8
+ -- because it is the one child declared ON DELETE CASCADE. Read on for the three failures that
9
+ -- produced the lessons; read `0018` for the shape that works.
10
+ --
3
11
  -- ⚠️ That is not the intent, it is what D1 permits. SQLite cannot drop a column a CHECK names, and
4
12
  -- this one names itself. The way around it is a table rebuild, and `knowledge_nodes` carries six
5
13
  -- foreign keys, one of them from itself.
@@ -22,6 +30,13 @@
22
30
  -- A table rebuild with foreign keys is therefore not possible inside a migration file. It needs a
23
31
  -- session that drives the transaction itself.
24
32
  --
33
+ -- ⚠️ THAT CONCLUSION WAS WRONG, and the way it was wrong is worth more than the conclusion. The
34
+ -- three lessons above are all correct; what they did not contain was the fourth — do not RENAME at
35
+ -- all. Create the new table under the final name and insert the rows back under the name the
36
+ -- children have referenced the whole time, and there is nothing left for a deferred check to
37
+ -- complain about at COMMIT. `0005` found it, `0013` and `0016` repeated it, and `0018` used it to
38
+ -- drop this column at last (#86).
39
+ --
25
40
  -- What holds instead: the column sits in D1, nothing reads it, and `db.ts` writes a fixed value on
26
41
  -- insert because it is NOT NULL without a DEFAULT. The contract does not know it — for every
27
42
  -- consumer it is gone. What remains is one dead column, and that is the price of Intel running.
@@ -0,0 +1,38 @@
1
+ -- anchrd/intel#301: one vector per board card, and the record of which vectors a node has.
2
+ --
3
+ -- Vectorize is keyed by node id (`adapters/semantic-index`), so a board was ONE vector holding the
4
+ -- whole document while the full-text half had held one row per card since #285. "Where do I stand
5
+ -- with X" is the question a board exists to answer and it is an imprecise one: lexically it reaches
6
+ -- a card only where the searched word is written on it verbatim. A vector id therefore becomes
7
+ -- `<node id>#<task id>` for a board card and stays the bare node id for every other kind — no
8
+ -- existing vector changes its name, so no installation re-embeds its whole tree to get this.
9
+ --
10
+ -- This table is the D1 side of that index and nothing more: which vectors a node has, what text
11
+ -- each one was made from, and the passage a searcher is shown when that vector is the hit. It is
12
+ -- DERIVED like `node_fts` beside it, and `reindex` empties it so that a rebuild really rebuilds.
13
+ --
14
+ -- ⚠️ `fingerprint` is what keeps a board of three hundred cards from costing three hundred
15
+ -- embeddings per save. The indexing pass compares it against the text it is about to embed and
16
+ -- upserts only what changed; a row is written ONLY AFTER the upsert it describes succeeded. That is
17
+ -- why a fingerprint is stored rather than a timestamp: a pass that dies between the two leaves a
18
+ -- row missing, never a row claiming a vector that was never written, so the next pass repairs
19
+ -- itself instead of trusting a lie.
20
+ --
21
+ -- ⚠️ `chunk_key` is `''` for a node that has exactly one vector — every kind but `board`. NULL
22
+ -- would be the honest spelling and is the wrong one: NULLs are distinct from one another inside a
23
+ -- SQLite primary key, so two rows for the same node could both exist and neither would be found by
24
+ -- the other's write.
25
+ --
26
+ -- ⚠️ No foreign key on `node_id`, deliberately, and for the same reason `node_fts` has none: a
27
+ -- derived index must not be able to make a write to `nodes` fail, and every rebuild of `nodes`
28
+ -- (0005, 0013, 0016, 0018) has to carry each declared child through the detour those files describe.
29
+ -- A row that outlives its node is invisible anyway — hydration joins `nodes` and drops what is
30
+ -- archived or gone, exactly as it does for `node_fts`.
31
+ CREATE TABLE node_vectors (
32
+ node_id TEXT NOT NULL,
33
+ chunk_key TEXT NOT NULL,
34
+ version_id TEXT NOT NULL,
35
+ fingerprint TEXT NOT NULL,
36
+ passage TEXT NOT NULL,
37
+ PRIMARY KEY (node_id, chunk_key)
38
+ );
@@ -0,0 +1,97 @@
1
+ -- #86: `context_policy` leaves `nodes` for good.
2
+ --
3
+ -- #76 removed it from the contract, the UI and every MCP answer — for every consumer it has been
4
+ -- gone since. The COLUMN stayed because SQLite cannot drop one a CHECK names, and `db.ts` has been
5
+ -- writing the fixed value `'relevant'` into it ever since so that inserting a node works at all.
6
+ -- A dead column plus a line of code serving it: harmless, and ballast the next reader has to be
7
+ -- told about.
8
+ --
9
+ -- ⚠️ THE reason this ticket sat open for weeks is that three earlier attempts failed against
10
+ -- `--remote` while passing locally, and the file that recorded them (`0009_no_context_policy.sql`)
11
+ -- concluded that a table rebuild with foreign keys needs "a session that runs the transaction
12
+ -- itself, not a migration file". That conclusion is out of date: `0005`, `0013` and `0016` each
13
+ -- rebuilt this very table through an ordinary migration file, and `0016` did it against the live
14
+ -- database on 2026-08-08 with 120 nodes, every referencing table coming out with the count it went
15
+ -- in with.
16
+ --
17
+ -- ⚠️ `0013` and `0016` are GONE since #392 — they were the Agent and Board rebuilds, and the
18
+ -- feature was parked (#385). What they worked out is not gone with them: the recipe is in
19
+ -- `packages/api/CLAUDE.md`, which is where the next rebuild reads it, and the three points below
20
+ -- are this file's own copy of it.
21
+ --
22
+ -- ⚠️ One thing about that run is worth knowing before it is quoted as a precedent: the installation
23
+ -- held ZERO `node_links` rows, so the rescue below would have had nothing to rescue and the run
24
+ -- would have passed without exercising it at all. The one row it did carry was created for the
25
+ -- purpose, minutes before, by linking two throwaway documents. The proof was arranged, not found —
26
+ -- and the next person rebuilding this table has to arrange it again, because the installation still
27
+ -- has almost no links.
28
+ --
29
+ -- What the three of them worked out, and what this file copies rather than rediscovers:
30
+ --
31
+ -- 1. `PRAGMA foreign_keys = OFF` is IGNORED by D1 over the HTTP API. Locally miniflare obeys it,
32
+ -- so the integration test was green while the real database answered `FOREIGN KEY constraint
33
+ -- failed`. That is why the proof of a migration here is a run against `--remote`.
34
+ -- 2. The new table is created under the FINAL name rather than built beside the old one and
35
+ -- renamed over it. `ALTER TABLE ... RENAME` makes references FOLLOW the rename, so renaming
36
+ -- the old table out of the way quietly re-points every other table at a table about to be
37
+ -- dropped. Inserting the rows again under the name the children have referenced all along is
38
+ -- what settles them.
39
+ -- 3. `DROP TABLE` on a parent runs an implicit `DELETE FROM` first, and `node_links` is the one
40
+ -- child declared ON DELETE CASCADE — so that delete does not merely flag its rows, it REMOVES
41
+ -- them. They are carried out of the way and put back. That is a rescue, not a decision about
42
+ -- the data. ⚠️ The list of children is in `packages/api/CLAUDE.md` and is READ FROM THE
43
+ -- DATABASE, not from these files: `0011` renamed the tree and SQLite rewrote the `REFERENCES`
44
+ -- clauses of tables older than it, so their current shape is written down nowhere here.
45
+ --
46
+ -- ⚠️ On an empty database neither detour is visible, because nothing points at anything. That is
47
+ -- how `0005`'s first version passed a green suite and then failed against the first database with
48
+ -- content in it, and why the proof for this file is a row count of every referencing table before
49
+ -- and after rather than a migration that merely ran.
50
+ PRAGMA defer_foreign_keys = TRUE;
51
+
52
+ -- Plain holding tables on purpose: no keys, no CHECKs, no foreign keys, and the column set taken
53
+ -- from whatever the live table has. Anything enforced here would only be enforced a second time on
54
+ -- the way back in, and a holding table that can reject a row is a holding table that can lose one.
55
+ CREATE TABLE nodes_carry AS SELECT * FROM nodes;
56
+ CREATE TABLE node_links_carry AS SELECT * FROM node_links;
57
+
58
+ DROP TABLE nodes;
59
+
60
+ -- The same table as `0016` left it, minus one column. Nothing else about it changes: same keys,
61
+ -- same CHECKs, same six kinds — a rebuild is the only way to drop the column, not an invitation to
62
+ -- change anything else while the table is open.
63
+ CREATE TABLE nodes (
64
+ id TEXT PRIMARY KEY NOT NULL,
65
+ parent_id TEXT REFERENCES nodes(id),
66
+ kind TEXT NOT NULL CHECK (kind IN ('folder', 'document', 'attachment', 'table')),
67
+ title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 240),
68
+ description TEXT CHECK (description IS NULL OR length(description) <= 2000),
69
+ owner_id TEXT NOT NULL,
70
+ current_version_id TEXT,
71
+ created_at TEXT NOT NULL,
72
+ updated_at TEXT NOT NULL,
73
+ archived_at TEXT
74
+ );
75
+
76
+ -- Columns named on both sides rather than `SELECT *`: the holding table still HAS `context_policy`,
77
+ -- and a positional insert would either fail or, worse, shift every value one place to the left.
78
+ INSERT INTO nodes (
79
+ id, parent_id, kind, title, description, owner_id,
80
+ current_version_id, created_at, updated_at, archived_at
81
+ )
82
+ SELECT
83
+ id, parent_id, kind, title, description, owner_id,
84
+ current_version_id, created_at, updated_at, archived_at
85
+ FROM nodes_carry;
86
+
87
+ -- `OR IGNORE` because whether the cascade above actually fired is SQLite's business, not this
88
+ -- migration's: if it did, this puts the rows back; if it did not, each one is already present under
89
+ -- the same primary key and this is a no-op. Either way `node_links` ends up holding exactly what it
90
+ -- held before, which is the only outcome this statement is permitted to have.
91
+ INSERT OR IGNORE INTO node_links SELECT * FROM node_links_carry;
92
+
93
+ DROP TABLE nodes_carry;
94
+ DROP TABLE node_links_carry;
95
+
96
+ CREATE INDEX nodes_parent_idx ON nodes(parent_id, archived_at, title);
97
+ CREATE INDEX nodes_owner_idx ON nodes(owner_id, archived_at);
@@ -0,0 +1,52 @@
1
+ -- #392: the grant table takes the tree's name, and the table nobody reads goes.
2
+ --
3
+ -- Two changes to the same subject, in one file because they are one subject. After Agents and Board
4
+ -- were parked (#385) `tree_grants` is the ONLY grant table left, and it stands beside
5
+ -- `node_versions`, `node_links`, `node_vectors` and `node_index_state` under a name from a different
6
+ -- vocabulary. `node_grants` is the name somebody would guess without looking.
7
+ --
8
+ -- ⚠️ It also closes a split that ran through four layers. D1 said `grants`, the contract says
9
+ -- `ListGrantsInput`/`RevokeGrantInput`, HTTP says `/nodes/:nodeId/grants` — and the MCP surface
10
+ -- alone said `share`. anchrd/intel#396 pulls the tools onto `grant`; this is the other half of the
11
+ -- same move, and afterwards the same thing has the same name everywhere.
12
+ --
13
+ -- ⚠️ A rename is safe HERE and would not be one table up. `ALTER TABLE ... RENAME` makes every
14
+ -- `REFERENCES` clause pointing AT the renamed table follow it — which is the trap the `nodes`
15
+ -- rebuild exists to avoid (see `packages/api/CLAUDE.md`). `tree_grants` is a child: nothing points
16
+ -- at it, so nothing can follow it anywhere. The direction is what makes the difference, not the
17
+ -- statement.
18
+ --
19
+ -- ⚠️ Indexes do NOT follow a rename. They keep working — an index is bound to its table, not to its
20
+ -- table's name — but they keep the old name in `sqlite_master` and would be the last place
21
+ -- `tree_` survives. That is the lesson `0011` wrote down, and dropping and recreating one is free:
22
+ -- an index holds no rows of its own and nothing points at it.
23
+ ALTER TABLE tree_grants RENAME TO node_grants;
24
+
25
+ DROP INDEX tree_grants_principal_idx;
26
+ DROP INDEX tree_grants_node_idx;
27
+
28
+ CREATE INDEX node_grants_principal_idx ON node_grants(
29
+ principal_type,
30
+ principal_id,
31
+ verb,
32
+ expires_at
33
+ );
34
+
35
+ CREATE INDEX node_grants_node_idx ON node_grants(node_id, verb);
36
+
37
+ -- `resource_grants` has been dead since `0003`, which said so in its own header:
38
+ --
39
+ -- > `resource_grants` is deliberately left in place and untouched. The previous version of the
40
+ -- > Worker reads it and keeps answering correctly until it is replaced; nothing in this version
41
+ -- > reads it any more. Once no old Worker is left, the table holds only history and can be
42
+ -- > dropped.
43
+ --
44
+ -- There is no old Worker. The condition has been met for five migrations and nobody announced the
45
+ -- moment, because nothing forced one — and a customer should not receive a schema carrying a table
46
+ -- no code has read since `0003`.
47
+ --
48
+ -- ⚠️ Dropped here rather than removed from `0000`, and that is deliberate. `0003` reads it: it is
49
+ -- the source every `tree_grants` row was migrated FROM. Taking it out of `0000` would leave `0003`
50
+ -- selecting from a table that never existed, so the history has to keep it and the end of the chain
51
+ -- is where it can go. `0001` also deletes rows from it — same reason, same answer.
52
+ DROP TABLE resource_grants;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.13.0",
3
+ "version": "0.15.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.11.0",
46
+ "@anchrd/intel-contract": "^0.13.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",
@@ -1,22 +0,0 @@
1
- import type { CloudflareAccountApi } from "./cloudflare-api.types.js";
2
- /**
3
- * ⚠️ `Authorization: Bearer`, and NOT `cf-aig-authorization`. The two hosts take different headers
4
- * and the agent runtime uses the other one: `gateway.ai.cloudflare.com` reads
5
- * `cf-aig-authorization`, this REST host reads the plain header. Swapping them produces a 401 that
6
- * says nothing about which of the two was wrong.
7
- */
8
- export interface CloudflareApiDeps {
9
- accountId: string;
10
- gatewayId: string;
11
- /**
12
- * A Cloudflare API token, read-only by intent.
13
- *
14
- * ⚠️ It is NOT the `AI_GATEWAY_TOKEN` the agent runtime holds. That one carries
15
- * `AI Gateway: Run` and buys inference; this one carries `AI Gateway: Read` (and, for the model
16
- * catalog, `Workers AI: Read`) and buys nothing at all. One credential for both would put a
17
- * spending permission into the Worker that only ever reads.
18
- */
19
- token: string;
20
- fetch: typeof fetch;
21
- }
22
- export declare function createCloudflareApi(deps: CloudflareApiDeps): CloudflareAccountApi;
@@ -1,214 +0,0 @@
1
- import { z } from "zod";
2
- import { IntelError } from "../../shared/intel-error/intel-error.js";
3
- const ApiOrigin = "https://api.cloudflare.com/client/v4";
4
- /**
5
- * How many pages of gateway log are read before the answer is declared a floor.
6
- *
7
- * A run is several model turns and a five-minute schedule is 8 640 runs a month, so "read
8
- * everything" is not on the table. Twenty pages of fifty is the last thirty days of a busy agent
9
- * or the last few days of a very busy one; past that the screen says "at least this much" rather
10
- * than a number nobody can check.
11
- *
12
- * ⚠️ `PerPage` is Cloudflare's ceiling, not a chosen number. The endpoint answers `per_page=100`
13
- * with `HTTP 400` and `Number must be less than or equal to 50`, the adapter turns that into
14
- * `cloudflare_api_refused`, and the screen says `unreadable` — so the whole cost view read nothing,
15
- * ever, and looked like an outage while doing it (#294). Raising it back is not an optimisation;
16
- * it switches the feature off. `MaxPages` carries the reach instead: the product of the two is what
17
- * "the last thousand calls" means, and lowering one without raising the other halves the window
18
- * silently.
19
- */
20
- const MaxPages = 20;
21
- const PerPage = 50;
22
- /**
23
- * ⚠️ Cloudflare's ceiling on `/ai/models/search`, and a DIFFERENT number from `PerPage` above —
24
- * the limit belongs to the endpoint, not to the account. Neither may be copied onto the other's
25
- * call: 50 on the model catalog halves it, 100 on the log is refused outright.
26
- *
27
- * And the two fail in opposite ways, of which this is the worse one. The log endpoint REFUSES with
28
- * `HTTP 400 Number must be less than or equal to 50` — loud, and found in a day (#294). This one
29
- * IGNORES: measured against the live account (#297), `per_page=200` and `per_page=1000` both answer
30
- * `HTTP 200` with no error and `result_info.per_page: 100`.
31
- *
32
- * ⚠️ `result_info.total_count` cannot be used to notice a short answer either. The same account
33
- * reports `total_count: 286` and returns 61 entries on page 1, with page 2 empty — a reader that
34
- * paginated on that figure would loop over empty pages and call the result partial. The truthful
35
- * signal is a page that came back FULL, which the log reader already uses and this one does not
36
- * yet (anchrd/intel#330).
37
- */
38
- const ModelPerPage = 100;
39
- /**
40
- * The gateway's log entry, read tolerantly.
41
- *
42
- * ⚠️ `metadata` arrives as an object on some responses and as a JSON string on others, and neither
43
- * is documented as the one shape. Both are accepted; anything else means the call is unattributed,
44
- * which is a state the caller can see rather than a parse failure that blanks the whole window.
45
- */
46
- const LogEntry = z.object({
47
- cost: z.number().nullish(),
48
- model: z.string().nullish(),
49
- created_at: z.string().nullish(),
50
- metadata: z.union([z.string(), z.record(z.string(), z.unknown())]).nullish(),
51
- });
52
- /**
53
- * ⚠️ `result` is required, only its contents may be null. An optional field would let ANY JSON body
54
- * parse as an empty page — and an empty page reads as "this agent cost nothing", which is the one
55
- * answer this whole path exists to avoid giving by accident.
56
- */
57
- const LogResponse = z.object({
58
- success: z.boolean().nullish(),
59
- result: z.array(LogEntry).nullable(),
60
- });
61
- function readMetadata(raw) {
62
- if (typeof raw === "string") {
63
- try {
64
- const parsed = JSON.parse(raw);
65
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
66
- ? parsed
67
- : {};
68
- }
69
- catch {
70
- return {};
71
- }
72
- }
73
- return raw && typeof raw === "object" && !Array.isArray(raw)
74
- ? raw
75
- : {};
76
- }
77
- /**
78
- * ⚠️ Cloudflare's `properties` are a list of `{property_id, value}` pairs, not fields, and `value`
79
- * is a string for the scalars and an array of `{unit, price, currency}` for the price. The units are
80
- * the provider's own wording — "per M input tokens" — so they are matched loosely and never parsed
81
- * as a contract: an unrecognised unit costs a missing price, and a missing price shows nothing,
82
- * which is the behaviour #257 asks for anyway.
83
- */
84
- const PriceEntry = z.object({
85
- unit: z.string(),
86
- price: z.union([z.number(), z.string()]),
87
- currency: z.string().nullish(),
88
- });
89
- const ModelProperty = z.object({
90
- property_id: z.string(),
91
- value: z.union([z.string(), z.number(), z.boolean(), z.array(PriceEntry)]),
92
- });
93
- const ModelEntry = z.object({
94
- name: z.string(),
95
- properties: z.array(ModelProperty).nullish(),
96
- });
97
- // Required for the same reason `LogResponse.result` is: a body this reader does not recognise must
98
- // not come out as "the account offers no models".
99
- const ModelResponse = z.object({
100
- success: z.boolean().nullish(),
101
- result: z.array(ModelEntry).nullable(),
102
- });
103
- function propertyOf(properties, id) {
104
- return properties.find((property) => property.property_id === id)?.value;
105
- }
106
- function priceFor(entries, side) {
107
- const found = entries.find((entry) => {
108
- const unit = entry.unit.toLowerCase();
109
- return unit.includes(side) && unit.includes("token") && /\bm\b|million/.test(unit);
110
- });
111
- if (!found)
112
- return undefined;
113
- const value = typeof found.price === "string" ? Number(found.price) : found.price;
114
- return Number.isFinite(value) ? value : undefined;
115
- }
116
- function readModel(entry) {
117
- const properties = entry.properties ?? [];
118
- const context = Number(propertyOf(properties, "context_window"));
119
- const rawPrice = propertyOf(properties, "price");
120
- const prices = Array.isArray(rawPrice) ? rawPrice : [];
121
- const input = priceFor(prices, "input");
122
- const output = priceFor(prices, "output");
123
- return {
124
- name: entry.name,
125
- contextTokens: Number.isFinite(context) && context > 0 ? context : null,
126
- // ⚠️ Both halves or neither. A model shown with an input price and no output price reads as if
127
- // its answers were free, which is a worse statement than saying nothing.
128
- price: input !== undefined && output !== undefined
129
- ? { inputPerMillion: input, outputPerMillion: output }
130
- : null,
131
- functionCalling: String(propertyOf(properties, "function_calling") ?? "") === "true",
132
- };
133
- }
134
- export function createCloudflareApi(deps) {
135
- /**
136
- * ⚠️ Neither the URL nor the body of a refusal is quoted onward. The URL carries the account id
137
- * and the body carries whatever Cloudflare wrote about a token; this message is read by a person
138
- * on a screen and by a model through the MCP surface alike. The status is kept, because it is the
139
- * whole of what an operator can act on: 401/403 is the token's permissions, 404 is the gateway id,
140
- * 429 is a limit.
141
- */
142
- async function get(path, query) {
143
- const url = new URL(`${ApiOrigin}${path}`);
144
- for (const [key, value] of Object.entries(query))
145
- url.searchParams.set(key, value);
146
- let response;
147
- try {
148
- response = await deps.fetch(url, {
149
- headers: { authorization: `Bearer ${deps.token}`, accept: "application/json" },
150
- });
151
- }
152
- catch {
153
- throw new IntelError(502, "cloudflare_api_unreachable", "The Cloudflare API did not answer");
154
- }
155
- if (!response.ok) {
156
- throw new IntelError(502, "cloudflare_api_refused", `The Cloudflare API refused this read (HTTP ${response.status})`);
157
- }
158
- return await response.json().catch(() => null);
159
- }
160
- return {
161
- async gatewayCalls(query) {
162
- const calls = [];
163
- let partial = false;
164
- for (let page = 1; page <= MaxPages; page += 1) {
165
- const body = await get(`/accounts/${encodeURIComponent(deps.accountId)}/ai-gateway/gateways/${encodeURIComponent(deps.gatewayId)}/logs`, {
166
- page: String(page),
167
- per_page: String(PerPage),
168
- start_date: query.since.toISOString(),
169
- end_date: query.until.toISOString(),
170
- order_by: "created_at",
171
- order_by_direction: "desc",
172
- // ⚠️ Only documented scalar parameters travel. The endpoint also takes a `filters` array
173
- // whose query encoding Cloudflare documents nowhere — neither the reference nor the
174
- // curl example shows it — so a guess at it would either be ignored (a slow read) or
175
- // rejected (no read at all), and there is no way to tell those apart from the status.
176
- // The agent is therefore picked out below, from the metadata the runtime stamped.
177
- });
178
- const parsed = LogResponse.safeParse(body);
179
- // A shape this reader cannot make sense of is a failure, not an empty window: an empty
180
- // window reads as "this agent cost nothing".
181
- if (!parsed.success) {
182
- throw new IntelError(502, "cloudflare_api_unreadable", "The Cloudflare API answered in a shape this version does not understand");
183
- }
184
- const entries = parsed.data.result ?? [];
185
- for (const entry of entries) {
186
- const metadata = readMetadata(entry.metadata);
187
- if (metadata.agentId !== query.agentId)
188
- continue;
189
- calls.push({
190
- runId: typeof metadata.runId === "string" ? metadata.runId : null,
191
- model: entry.model ?? "",
192
- cost: entry.cost ?? 0,
193
- at: entry.created_at ?? query.until.toISOString(),
194
- });
195
- }
196
- if (entries.length < PerPage)
197
- return { calls, partial: false };
198
- partial = page === MaxPages;
199
- }
200
- return { calls, partial };
201
- },
202
- async workersAiModels() {
203
- const body = await get(`/accounts/${encodeURIComponent(deps.accountId)}/ai/models/search`, {
204
- per_page: String(ModelPerPage),
205
- hide_experimental: "true",
206
- });
207
- const parsed = ModelResponse.safeParse(body);
208
- if (!parsed.success) {
209
- throw new IntelError(502, "cloudflare_api_unreadable", "The Cloudflare API answered in a shape this version does not understand");
210
- }
211
- return (parsed.data.result ?? []).map(readModel);
212
- },
213
- };
214
- }
@@ -1,64 +0,0 @@
1
- /**
2
- * The two things Intel reads out of the Cloudflare account, and nothing else.
3
- *
4
- * ⚠️ Both are READS, and the port says so by having no other verb. The token behind it is
5
- * account-wide — Cloudflare offers no per-gateway scope for `AI Gateway: Read`, the same limitation
6
- * `AI Gateway: Run` already had in #239 — so the narrowness has to come from what this interface
7
- * can express rather than from what the credential allows.
8
- */
9
- export interface CloudflareAccountApi {
10
- /**
11
- * The gateway's own log lines for one agent, within a window.
12
- *
13
- * ⚠️ `cost` here is the **debit from the Cloudflare balance**, 1:1 — measured on 2026-08-07
14
- * against the running installation: balance $19.77 + spend $0.23 = the $20.00 that was loaded.
15
- * Cloudflare takes its 5 % when the balance is topped up and passes inference through without a
16
- * markup, so this number means "what this costs us" and needs no conversion. A reader who
17
- * multiplied it by anything would be inventing a second, wrong price.
18
- */
19
- gatewayCalls(query: GatewayCallQuery): Promise<GatewayCallPage>;
20
- /**
21
- * What Cloudflare currently charges for the models it serves itself (#257).
22
- *
23
- * ⚠️ Workers AI only. Cloudflare publishes no price list for the Anthropic models it resells
24
- * through Unified Billing, so those figures have no live source and stay a table — which is the
25
- * whole reason the catalog says, per entry, where its numbers came from.
26
- */
27
- workersAiModels(): Promise<WorkersAiModel[]>;
28
- }
29
- export interface GatewayCallQuery {
30
- /** The value stamped as `cf-aig-metadata.agentId` by the agent runtime. */
31
- agentId: string;
32
- since: Date;
33
- until: Date;
34
- }
35
- export interface GatewayCall {
36
- /** From `cf-aig-metadata.runId`. `null` for a call made before the stamp existed. */
37
- runId: string | null;
38
- model: string;
39
- /** US dollars, as billed. */
40
- cost: number;
41
- at: string;
42
- }
43
- export interface GatewayCallPage {
44
- calls: GatewayCall[];
45
- /**
46
- * The window was cut off at the page cap, so every total built from it is a floor rather than a
47
- * total.
48
- *
49
- * ⚠️ It exists so the screen can say "at least". A sum that silently stopped counting is the same
50
- * failure as a missing number pretending to be zero, only harder to notice.
51
- */
52
- partial: boolean;
53
- }
54
- export interface WorkersAiModel {
55
- /** The full `@cf/...` id, exactly as a definition names it. */
56
- name: string;
57
- contextTokens: number | null;
58
- price: {
59
- inputPerMillion: number;
60
- outputPerMillion: number;
61
- } | null;
62
- /** Whether this model can call a tool at all. An agent is a tool loop; one that cannot is useless. */
63
- functionCalling: boolean;
64
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,23 +0,0 @@
1
- import type { AgentApplications } from "../../nodes/nodes.types.js";
2
- /**
3
- * Intel's door to Gate's Applications surface (`anchrd/gate#223`/`#224`, Gate 0.10.x).
4
- *
5
- * ⚠️ This is the one Gate call Intel makes with the CALLER's bearer instead of its service key, and
6
- * that is not a shortcut — the service key does not open this door. A Gate service key authorizes
7
- * `/api/v1/authorization` and `/api/v1/schema` and nothing else (Gate's two-token rule); the
8
- * Applications routes are admin-gated and resolve a real principal, so the person creating an agent
9
- * needs `applications:write` in Gate and their act is audited in Gate under their own name. A
10
- * service key here would have made Intel the author of every machine principal an installation ever
11
- * grew, which is the opposite of what an audit trail is for.
12
- *
13
- * ⚠️ Nothing this module receives from Gate is logged, wrapped into a message, or returned other
14
- * than through the one typed answer below. `create` is handed a plain-text key, and the shortest
15
- * path from here to a leak is an error that quotes the response body — so no refusal names anything
16
- * but the status Gate answered with.
17
- */
18
- export interface GateApplicationsDeps {
19
- fetch: (url: string, init?: RequestInit) => Promise<Response>;
20
- gateUrl: string;
21
- timeoutMs?: number;
22
- }
23
- export declare function createGateApplications(deps: GateApplicationsDeps): AgentApplications;
@@ -1,88 +0,0 @@
1
- import { z } from "zod";
2
- import { IntelError } from "../../shared/intel-error/intel-error.js";
3
- // Tolerant on purpose, unlike Intel's own contracts: this is somebody else's wire format, and a
4
- // field Gate adds tomorrow must not stop an installation from creating an agent. Only what Intel
5
- // actually reads is named.
6
- const CreatedApplication = z.object({ id: z.string().min(1), key: z.string().min(1) });
7
- // The same tolerance, for the same reason. Only the key is read: the ID is the one Intel asked with.
8
- const RotatedApplication = z.object({ key: z.string().min(1) });
9
- const DefaultTimeoutMs = 10_000;
10
- export function createGateApplications(deps) {
11
- const base = deps.gateUrl.replace(/\/+$/, "");
12
- const timeoutMs = deps.timeoutMs ?? DefaultTimeoutMs;
13
- // Gate's refusals, translated once. A 401/403 is the caller's missing `applications:write` and is
14
- // permanent until somebody acts in Gate, so it is answered as a refusal rather than as an outage;
15
- // everything else — a 5xx, a timeout, a DNS failure — is "Gate did not answer", and the caller is
16
- // told that no agent was created rather than left to guess.
17
- function refusal(status) {
18
- if (status === 401 || status === 403) {
19
- return new IntelError(403, "agent_application_forbidden", "Gate refused this account the management of applications — creating an agent needs the applications permission in Gate");
20
- }
21
- return new IntelError(502, "agent_application_unavailable", `Gate could not manage this agent's application (${status})`);
22
- }
23
- async function call(path, token, body) {
24
- try {
25
- return await deps.fetch(`${base}/api/v1/applications${path}`, {
26
- method: "POST",
27
- headers: {
28
- "content-type": "application/json",
29
- authorization: `Bearer ${token}`,
30
- },
31
- body: JSON.stringify(body),
32
- signal: AbortSignal.timeout(timeoutMs),
33
- });
34
- }
35
- catch {
36
- // ⚠️ The caught error is dropped rather than described. A fetch failure carries the URL, and
37
- // the URL is the one place the bearer could still be if a caller ever put it in a query.
38
- throw new IntelError(502, "agent_application_unavailable", "Gate did not answer, so no agent application was created or changed");
39
- }
40
- }
41
- return {
42
- async create(input) {
43
- const response = await call("", input.token, { name: input.name });
44
- if (!response.ok)
45
- throw refusal(response.status);
46
- const parsed = CreatedApplication.safeParse(await response.json().catch(() => null));
47
- if (!parsed.success) {
48
- // ⚠️ A 2xx Intel cannot read means a principal MAY exist in Gate that Intel cannot record.
49
- // Nothing has been written on this side yet, so the agent does not come into being; the
50
- // operator finds an unused application in Gate's list rather than an agent that half works.
51
- throw new IntelError(502, "agent_application_unavailable", "Gate answered the application creation in a shape Intel cannot read");
52
- }
53
- return { id: parsed.data.id, key: parsed.data.key };
54
- },
55
- async rotateKey(input) {
56
- // Gate issues the replacement FIRST and only then revokes what was there, so a rotation that
57
- // fails leaves the old key working rather than locking the agent out (`applications.ts` in
58
- // `anchrd/gate`). Intel relies on that: the handover to the runtime happens after this call,
59
- // and until it succeeds the agent keeps running on the key it had.
60
- const response = await call(`/${encodeURIComponent(input.applicationId)}/rotate-key`, input.token, {});
61
- if (response.status === 404) {
62
- throw new IntelError(502, "agent_application_missing", "Gate does not know this agent's application any more");
63
- }
64
- if (!response.ok)
65
- throw refusal(response.status);
66
- const parsed = RotatedApplication.safeParse(await response.json().catch(() => null));
67
- if (!parsed.success) {
68
- // ⚠️ A 2xx Intel cannot read means the OLD key is already revoked in Gate and the new one is
69
- // lost. The agent is broken either way, so the caller is told the rotation failed and rotates
70
- // again — which is safe, because rotating twice is just another new key.
71
- throw new IntelError(502, "agent_application_unavailable", "Gate answered the key rotation in a shape Intel cannot read");
72
- }
73
- return { key: parsed.data.key };
74
- },
75
- async setEnabled(input) {
76
- // Both routes are idempotent in Gate, which is what lets a retried archive heal a run that
77
- // failed between the two writes instead of needing a repair path of its own.
78
- const response = await call(`/${encodeURIComponent(input.applicationId)}/${input.enabled ? "enable" : "disable"}`, input.token, {});
79
- // A 404 is the one status worth separating: the Application behind this agent is gone from
80
- // Gate, and telling the operator that is more use than a generic outage they would retry.
81
- if (response.status === 404) {
82
- throw new IntelError(502, "agent_application_missing", "Gate does not know this agent's application any more");
83
- }
84
- if (!response.ok)
85
- throw refusal(response.status);
86
- },
87
- };
88
- }