teapot-coding-agent 0.37.59 → 0.37.61
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/dist/acp/adapter.js +4 -72
- package/dist/agent/agent.js +61 -2247
- package/dist/agent/compact-rules.js +0 -53
- package/dist/agent/editorconfig.js +4 -56
- package/dist/agent/goal-audit.js +0 -34
- package/dist/agent/key-pool.js +0 -52
- package/dist/agent/kill.js +1 -13
- package/dist/agent/llm.js +9 -310
- package/dist/agent/mcp-oauth-manager.js +2 -255
- package/dist/agent/mcp-registry.js +1 -188
- package/dist/agent/mcp.js +1 -85
- package/dist/agent/meta-tools.js +0 -26
- package/dist/agent/plugins.js +1 -81
- package/dist/agent/provider-headers.js +1 -28
- package/dist/agent/skills.js +8 -94
- package/dist/agent/tools.js +16 -809
- package/dist/bus.js +1 -2
- package/dist/config-schema.js +0 -40
- package/dist/home.js +1 -19
- package/dist/index.js +0 -136
- package/dist/log/events.js +3 -195
- package/dist/master.js +31 -1180
- package/dist/migrations/index.js +0 -55
- package/dist/migrations/providers.js +0 -13
- package/dist/migrations/registry.js +0 -9
- package/dist/migrations/session-titles.js +3 -43
- package/dist/migrations/sessions.js +2 -20
- package/dist/model-meta.js +166 -142
- package/dist/scheduler/cron.js +2 -17
- package/dist/server/api.js +12 -778
- package/dist/server/image-resize.js +1 -64
- package/dist/server/term-size.js +1 -120
- package/dist/server/terminals.js +0 -159
- package/dist/session-title.js +2 -35
- package/package.json +1 -1
- package/public/assets/{index-KzHw4Sqo.js → index-C1eqcSbW.js} +4 -4
- package/public/assets/index-C1eqcSbW.js.map +1 -0
- package/public/assets/{shiki-BwDjoZdu.js → shiki-DaLn_bCe.js} +2 -2
- package/public/assets/shiki-DaLn_bCe.js.map +1 -0
- package/public/index.html +1 -1
- package/public/assets/index-KzHw4Sqo.js.map +0 -1
- package/public/assets/shiki-BwDjoZdu.js.map +0 -1
package/dist/migrations/index.js
CHANGED
|
@@ -1,40 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Ordered, one-way migrations for on-disk state.
|
|
3
|
-
*
|
|
4
|
-
* ## Why order and idempotency matter more than the transformations
|
|
5
|
-
*
|
|
6
|
-
* A single upgrade path is easy. The hard case is a machine that skips several releases: an
|
|
7
|
-
* operator on v0.20 upgrading straight to v0.40 runs EVERY migration in between at once. Two
|
|
8
|
-
* migrations that each look correct can then be wrong together — the canonical example is a future
|
|
9
|
-
* `config.json` split combined with today's "store the session title in `agents[]`":
|
|
10
|
-
*
|
|
11
|
-
* split first → titles land in the new config directory, then the old file is read again and
|
|
12
|
-
* the titles are written a second time, or to the wrong file;
|
|
13
|
-
* titles first → the split then has to carry a field it never knew about.
|
|
14
|
-
*
|
|
15
|
-
* So each migration here:
|
|
16
|
-
*
|
|
17
|
-
* - is **idempotent** — running it twice is a no-op, because a crash mid-upgrade must be
|
|
18
|
-
* resumable and a re-run must not double-apply;
|
|
19
|
-
* - declares an **order** — `order` is ascending, and a later step may assume every earlier one
|
|
20
|
-
* has run;
|
|
21
|
-
* - never deletes data it does not fully replace (a rename, not a delete-then-write);
|
|
22
|
-
* - reports what it did, so a boot log shows which steps actually ran.
|
|
23
|
-
*
|
|
24
|
-
* ## What this module is NOT
|
|
25
|
-
*
|
|
26
|
-
* It does not decide WHICH migrations exist — each one lives in its own file and is registered in
|
|
27
|
-
* `index.ts`. Keeping them out of `master.ts` is the point: they were spread across the boot path,
|
|
28
|
-
* where an ordering mistake is invisible.
|
|
29
|
-
*/
|
|
30
|
-
/**
|
|
31
|
-
* Run every registered migration in `order`, skipping nothing.
|
|
32
|
-
*
|
|
33
|
-
* There is no "already ran" gate on purpose: each step is idempotent, so the cheapest correct
|
|
34
|
-
* policy is to always run them. A recorded version would have to be validated against a config the
|
|
35
|
-
* operator may have hand-edited or restored from backup, and a wrong version claim is how a
|
|
36
|
-
* migration silently does not run.
|
|
37
|
-
*/
|
|
38
1
|
export async function runMigrations(migrations, ctx) {
|
|
39
2
|
const applied = [];
|
|
40
3
|
for (const m of [...migrations].sort((a, b) => a.order - b.order)) {
|
|
@@ -45,26 +8,8 @@ export async function runMigrations(migrations, ctx) {
|
|
|
45
8
|
}
|
|
46
9
|
}
|
|
47
10
|
catch (err) {
|
|
48
|
-
// One broken step must not block the boot: the session data is still readable, and refusing
|
|
49
|
-
// to start would be worse than starting with one conversion unmade.
|
|
50
11
|
ctx.log(`migration ${m.id} FAILED (${err.message}) — continuing without it`);
|
|
51
12
|
}
|
|
52
13
|
}
|
|
53
14
|
return { applied };
|
|
54
15
|
}
|
|
55
|
-
/**
|
|
56
|
-
* ## What is deliberately NOT here
|
|
57
|
-
*
|
|
58
|
-
* Two migrations stay in `src/agent/agent.ts` because they are per-SESSION filesystem moves that
|
|
59
|
-
* need the agent's own paths, and they already satisfy the rules above:
|
|
60
|
-
*
|
|
61
|
-
* - `migrateGoalFromWorkspace` — `<workspace>/GOAL.md` → the session's goal store. Deletes the
|
|
62
|
-
* legacy file only after a successful write, so a failure re-runs.
|
|
63
|
-
* - `migrateMemoryToAgentScope` — a session's `memory.md` → the agent-wide one. APPENDS (the
|
|
64
|
-
* agent-wide file may already hold other sessions' notes), then removes the source. A missing
|
|
65
|
-
* write leaves the session copy in place.
|
|
66
|
-
*
|
|
67
|
-
* Moving them here would mean handing this module session paths it has no business knowing. What
|
|
68
|
-
* matters is that they obey the same contract: idempotent, no data destroyed that was not
|
|
69
|
-
* replaced, and ordered after the config-level steps (they run at agent start, which is later).
|
|
70
|
-
*/
|
|
@@ -1,15 +1,4 @@
|
|
|
1
1
|
import { migrateProviderKeys } from "../master.js";
|
|
2
|
-
/**
|
|
3
|
-
* Fold a legacy single `apiKey` into an `apiKeys` array.
|
|
4
|
-
*
|
|
5
|
-
* `apiKey` is KEPT in step with `apiKeys[0]` rather than deleted: other readers still expect it (the
|
|
6
|
-
* setup wizard, `/api/providers`, hand-edited files), and a migration that removes a field the
|
|
7
|
-
* operator can see is one they will report as data loss.
|
|
8
|
-
*
|
|
9
|
-
* Idempotent because `apiKeys` is AUTHORITATIVE once present: re-running on a migrated entry
|
|
10
|
-
* produces the same list. The single `apiKey` only SEEDS the pool when there is no list — appending
|
|
11
|
-
* it as well would resurrect a credential the operator had already replaced.
|
|
12
|
-
*/
|
|
13
2
|
export const providerKeys = {
|
|
14
3
|
order: 10,
|
|
15
4
|
id: "providers-api-keys",
|
|
@@ -25,8 +14,6 @@ export const providerKeys = {
|
|
|
25
14
|
continue;
|
|
26
15
|
const p = entry;
|
|
27
16
|
const before = JSON.stringify(p);
|
|
28
|
-
// ONE implementation of the folding rule, shared with the runtime normaliser — a second copy
|
|
29
|
-
// here is how the two drift and disagree about which key is authoritative.
|
|
30
17
|
const next = migrateProviderKeys(p);
|
|
31
18
|
for (const k of Object.keys(p))
|
|
32
19
|
delete p[k];
|
|
@@ -1,14 +1,5 @@
|
|
|
1
1
|
import { providerKeys } from "./providers.js";
|
|
2
2
|
import { flatSessionLayout } from "./sessions.js";
|
|
3
3
|
import { sessionTitles } from "./session-titles.js";
|
|
4
|
-
/**
|
|
5
|
-
* Every migration, in application order.
|
|
6
|
-
*
|
|
7
|
-
* Add new ones with a HIGHER `order` than everything here, and never renumber an existing one — a
|
|
8
|
-
* machine that already ran it would skip it under the new number.
|
|
9
|
-
*
|
|
10
|
-
* Ordering rule: a migration may assume every lower `order` has run. That is what makes a
|
|
11
|
-
* multi-release jump safe. If two migrations must not be reordered later, say so in both files.
|
|
12
|
-
*/
|
|
13
4
|
export const MIGRATIONS = [providerKeys, flatSessionLayout, sessionTitles];
|
|
14
5
|
export { runMigrations } from "./index.js";
|
|
@@ -1,20 +1,5 @@
|
|
|
1
1
|
import { closeSync, openSync, readSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
/**
|
|
4
|
-
* Derive each agent's session title ONCE and store it in `config.agents[].sessionTitle`.
|
|
5
|
-
*
|
|
6
|
-
* Titles used to be recomputed on every `allSessions()` — opening up to 830 session logs and
|
|
7
|
-
* allocating a 16KB buffer for each, MEASURED at 83.8ms, 8.1MB read and ~38MB of short-lived
|
|
8
|
-
* garbage per scan, on every `/api/sessions`. Storing it makes listing a config lookup.
|
|
9
|
-
*
|
|
10
|
-
* Runs ONCE PER SESSION rather than once per process: the check is `sessionTitle` on the agent's
|
|
11
|
-
* CURRENT session, so a machine that has already been through this does no file I/O at all. That
|
|
12
|
-
* is what makes it idempotent — and why it must come AFTER any step that could change `agents[]`.
|
|
13
|
-
*
|
|
14
|
-
* A session whose first prompt is a HARNESS prompt yields no title: sub-agent sessions receive only
|
|
15
|
-
* those and are labelled from their id, so inventing one would name a child after its parent's
|
|
16
|
-
* instruction.
|
|
17
|
-
*/
|
|
18
3
|
export const sessionTitles = {
|
|
19
4
|
order: 30,
|
|
20
5
|
id: "agents-session-title",
|
|
@@ -27,21 +12,11 @@ export const sessionTitles = {
|
|
|
27
12
|
const session = typeof a.session === "string" ? a.session : "";
|
|
28
13
|
if (!session)
|
|
29
14
|
continue;
|
|
30
|
-
// already stored (possibly hand-edited — a chosen name must never be overwritten)
|
|
31
15
|
if (typeof a.sessionTitle === "string" && a.sessionTitle.trim())
|
|
32
16
|
continue;
|
|
33
17
|
if (session.includes("/") || session.includes(".."))
|
|
34
|
-
continue;
|
|
18
|
+
continue;
|
|
35
19
|
const title = firstUserPromptText(path.join(ctx.dataDir, "sessions", session, "chat.jsonl"));
|
|
36
|
-
/**
|
|
37
|
-
* The SESSION ID is the fallback, not a marker.
|
|
38
|
-
*
|
|
39
|
-
* A sub-agent session receives only harness prompts, so no title can ever be derived from it —
|
|
40
|
-
* and a session id does not change either, so there is nothing to retry for. Writing the id
|
|
41
|
-
* makes the field ALWAYS present, which is what makes the pass idempotent without an extra
|
|
42
|
-
* flag: the next boot sees a title and skips. The sidebar already shows the id in this case,
|
|
43
|
-
* so nothing visible changes.
|
|
44
|
-
*/
|
|
45
20
|
a.sessionTitle = title ?? session;
|
|
46
21
|
changed = true;
|
|
47
22
|
}
|
|
@@ -50,19 +25,6 @@ export const sessionTitles = {
|
|
|
50
25
|
return changed;
|
|
51
26
|
},
|
|
52
27
|
};
|
|
53
|
-
/**
|
|
54
|
-
* The first USER prompt in a log, trimmed to a title. `null` when there is none.
|
|
55
|
-
*
|
|
56
|
-
* Bounded (256KB) and stops at the first prompt, so one enormous log cannot dominate boot. A torn
|
|
57
|
-
* final line at a chunk boundary is skipped rather than aborting the read.
|
|
58
|
-
*/
|
|
59
|
-
/**
|
|
60
|
-
* ONE reusable read buffer for the whole pass.
|
|
61
|
-
*
|
|
62
|
-
* `Buffer.alloc` per session is what made the first boot spike: 830 sessions x 16KB of short-lived
|
|
63
|
-
* buffer, MEASURED as heap 28.2MB → 108.5MB during `start()` (33.4MB after GC). The scan is a
|
|
64
|
-
* single-threaded loop, so sharing one buffer is safe and removes the churn entirely.
|
|
65
|
-
*/
|
|
66
28
|
let scanBuffer;
|
|
67
29
|
function firstUserPromptText(file) {
|
|
68
30
|
let fd;
|
|
@@ -89,12 +51,12 @@ function firstUserPromptText(file) {
|
|
|
89
51
|
e = JSON.parse(l);
|
|
90
52
|
}
|
|
91
53
|
catch {
|
|
92
|
-
continue;
|
|
54
|
+
continue;
|
|
93
55
|
}
|
|
94
56
|
if (e.type !== "prompt")
|
|
95
57
|
continue;
|
|
96
58
|
if (e.data?.source !== "user")
|
|
97
|
-
return null;
|
|
59
|
+
return null;
|
|
98
60
|
return titleOf(typeof e.data.text === "string" ? e.data.text : null);
|
|
99
61
|
}
|
|
100
62
|
}
|
|
@@ -109,12 +71,10 @@ function firstUserPromptText(file) {
|
|
|
109
71
|
closeSync(fd);
|
|
110
72
|
}
|
|
111
73
|
catch {
|
|
112
|
-
/* already closed */
|
|
113
74
|
}
|
|
114
75
|
}
|
|
115
76
|
}
|
|
116
77
|
}
|
|
117
|
-
/** the first usable line of a prompt, clipped — the sidebar shows one short label */
|
|
118
78
|
function titleOf(text) {
|
|
119
79
|
const line = (text ?? "")
|
|
120
80
|
.split("\n")
|
|
@@ -1,28 +1,10 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, renameSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
/**
|
|
4
|
-
* Move sessions from the ≤0.5.0 flat layout (`<dataDir>/<id>.jsonl`) into
|
|
5
|
-
* `<dataDir>/sessions/<id>/chat.jsonl`.
|
|
6
|
-
*
|
|
7
|
-
* A RENAME, not a copy-then-delete: a crash between the two would otherwise leave the history in
|
|
8
|
-
* neither place. `rename` within one filesystem is atomic, so a re-run either finds the legacy file
|
|
9
|
-
* (and moves it) or finds it already moved.
|
|
10
|
-
*
|
|
11
|
-
* Order 20: it must run before anything reads a session directory.
|
|
12
|
-
*/
|
|
13
3
|
export const flatSessionLayout = {
|
|
14
4
|
order: 20,
|
|
15
5
|
id: "sessions-flat-layout",
|
|
16
6
|
summary: "sessions/<id>.jsonl → sessions/<id>/chat.jsonl",
|
|
17
7
|
run(ctx) {
|
|
18
|
-
/**
|
|
19
|
-
* Scan the DATA DIR, not `config.agents`.
|
|
20
|
-
*
|
|
21
|
-
* A legacy log can exist for an agent that is no longer in the config (archived, removed, or a
|
|
22
|
-
* config that failed to load) — and those are exactly the histories worth rescuing. Iterating
|
|
23
|
-
* the config also made this a no-op whenever the config listed no agents, which is how a test
|
|
24
|
-
* with an empty agent list silently stopped exercising it.
|
|
25
|
-
*/
|
|
26
8
|
let names;
|
|
27
9
|
try {
|
|
28
10
|
names = readdirSync(ctx.dataDir);
|
|
@@ -37,11 +19,11 @@ export const flatSessionLayout = {
|
|
|
37
19
|
continue;
|
|
38
20
|
const id = name.slice(0, -".jsonl".length);
|
|
39
21
|
if (!id || id.includes("/") || id.includes(".."))
|
|
40
|
-
continue;
|
|
22
|
+
continue;
|
|
41
23
|
const legacy = path.join(ctx.dataDir, name);
|
|
42
24
|
const target = path.join(root, id);
|
|
43
25
|
if (existsSync(target))
|
|
44
|
-
continue;
|
|
26
|
+
continue;
|
|
45
27
|
mkdirSync(target, { recursive: true });
|
|
46
28
|
renameSync(legacy, path.join(target, "chat.jsonl"));
|
|
47
29
|
ctx.log(`${name} → sessions/${id}/chat.jsonl`);
|
package/dist/model-meta.js
CHANGED
|
@@ -1,180 +1,204 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Model capabilities, from models.dev.
|
|
3
|
+
*
|
|
4
|
+
* WHY models.dev and not the provider's own `/models`: MEASURED against the operator's four
|
|
5
|
+
* configured providers, only OpenRouter publishes anything about capabilities. Command Code (93
|
|
6
|
+
* models), Google (38) and orcarouter all return just an id, a name and a context length — no
|
|
7
|
+
* `modalities`, no `reasoning`, no `supported_parameters`. That is why this file used to conclude
|
|
8
|
+
* "cannot be known", which was WRONG: models.dev carries the same information for 232 providers,
|
|
9
|
+
* including `commandcode` (71 models) and `opencode` (130), keyed by the provider's own API host.
|
|
10
|
+
*
|
|
11
|
+
* The shape mirrors what Pi (and its forks) do — models.dev is the authority, `reasoning_options`
|
|
12
|
+
* becomes the selectable effort list, `modalities.input` becomes the accepted input kinds. Keeping a
|
|
13
|
+
* second parser for each provider's ad-hoc `/models` JSON was the "wasteful and a source of bugs"
|
|
14
|
+
* the operator called out, so it is gone.
|
|
5
15
|
*/
|
|
6
16
|
import { providerHeaders } from "./agent/provider-headers.js";
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
const TTL =
|
|
17
|
+
const DB_URL = "https://models.dev/api.json";
|
|
18
|
+
/** the whole DB is ~6MB; it changes on the order of days, so an hour is generous and cheap. */
|
|
19
|
+
const TTL = 60 * 60_000;
|
|
20
|
+
/** a failed fetch must not re-run per agent (15s × every agent at boot was the old bug) */
|
|
21
|
+
const NEGATIVE_TTL = 5 * 60_000;
|
|
22
|
+
let db;
|
|
23
|
+
let inFlight;
|
|
24
|
+
const hostOf = (baseUrl) => {
|
|
25
|
+
try {
|
|
26
|
+
return new URL(String(baseUrl ?? "")).host.toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const fresh = (s) => !!s && Date.now() - s.at < (s.negative ? NEGATIVE_TTL : TTL);
|
|
10
33
|
/**
|
|
11
|
-
*
|
|
34
|
+
* Find the models.dev provider for a baseUrl.
|
|
12
35
|
*
|
|
13
|
-
*
|
|
14
|
-
* caching), so a dead, unauthorised, or simply slow endpoint was re-fetched for
|
|
15
|
-
* EVERY agent pointed at it. `addAgent` awaits this twice per agent and races it
|
|
16
|
-
* against a 1.5 s timeout, so with 493 agents across a handful of providers the
|
|
17
|
-
* same failing request — and its timeout — repeated hundreds of times. MEASURED
|
|
18
|
-
* at boot: 1883 ms of the 4.6 s was idle, waiting on exactly this.
|
|
36
|
+
* HOST FIRST, then the path as a tie-break, because both are needed:
|
|
19
37
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return Date.now() - entry.at < (entry.negative ? NEGATIVE_TTL : TTL);
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* The catalogue if it is ALREADY known, without starting a fetch (#143).
|
|
38
|
+
* - a host match is what makes `commandcode` work: FIVE config entries share
|
|
39
|
+
* `api.commandcode.ai`, and models.dev lists that host once;
|
|
40
|
+
* - a host match alone is ambiguous for `opencode.ai`, which models.dev splits into `opencode`
|
|
41
|
+
* (130 models, `/inference/openai/v1`) and `opencode-go` (37, `/inference/go/openai/v1`).
|
|
42
|
+
* MEASURED: host-only picked whichever came first and resolved the session's provider to the
|
|
43
|
+
* wrong catalog — 37 models, and its model not found.
|
|
30
44
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* the reasoning-effort controls (#47). `prefetchModelLists()` starts the real
|
|
34
|
-
* lookups in parallel, so an endpoint that answers quickly is usually warm by the
|
|
35
|
-
* time its first agent is created, and a slow or dead one costs no boot time.
|
|
45
|
+
* The operator's `https://opencode.ai/zen/v1` matches NEITHER path exactly, so the path only
|
|
46
|
+
* disambiguates when it actually agrees; otherwise the host match stands.
|
|
36
47
|
*/
|
|
37
|
-
export function
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
if (!hit)
|
|
41
|
-
return undefined;
|
|
42
|
-
if (!fresh(hit))
|
|
48
|
+
export function providerFor(dbProviders, baseUrl) {
|
|
49
|
+
const host = hostOf(baseUrl);
|
|
50
|
+
if (!host)
|
|
43
51
|
return undefined;
|
|
44
|
-
|
|
52
|
+
const sameHost = [...dbProviders.values()].filter((p) => hostOf(p.api ?? undefined) === host);
|
|
53
|
+
if (sameHost.length <= 1)
|
|
54
|
+
return sameHost[0];
|
|
55
|
+
const path = (() => {
|
|
56
|
+
try {
|
|
57
|
+
return new URL(String(baseUrl ?? "")).pathname;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
})();
|
|
63
|
+
// prefer the entry whose path is a prefix/suffix relation with ours, longest overlap wins
|
|
64
|
+
const scored = sameHost
|
|
65
|
+
.map((p) => {
|
|
66
|
+
const apiPath = (() => {
|
|
67
|
+
try {
|
|
68
|
+
return new URL(String(p.api ?? "")).pathname;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return "";
|
|
72
|
+
}
|
|
73
|
+
})();
|
|
74
|
+
let common = 0;
|
|
75
|
+
for (let i = 0; i < Math.min(apiPath.length, path.length) && apiPath[i] === path[i]; i++)
|
|
76
|
+
common++;
|
|
77
|
+
/**
|
|
78
|
+
* When the shared prefix is only the leading `/`, the paths disagree entirely — `zen/v1` is
|
|
79
|
+
* neither `inference/openai/v1` nor `inference/go/openai/v1`. In that case prefer the entry
|
|
80
|
+
* with MORE models: a real provider catalog is the general one, and the narrower variant is a
|
|
81
|
+
* strict subset (opencode: 130 vs opencode-go: 37, MEASURED).
|
|
82
|
+
*/
|
|
83
|
+
const models = Object.keys(p.models ?? {}).length;
|
|
84
|
+
return { p, common, models };
|
|
85
|
+
})
|
|
86
|
+
.sort((a, b) => b.common - a.common || b.models - a.models);
|
|
87
|
+
return scored[0]?.p ?? sameHost[0];
|
|
45
88
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return hit.list;
|
|
52
|
-
// An in-flight lookup is SHARED: without this, two callers racing for the
|
|
53
|
-
// same endpoint each paid the full timeout, and `newSession` in ACP mode
|
|
54
|
-
// blocked on it before it could even reply to the editor (#15).
|
|
55
|
-
const inflight = inFlight.get(root);
|
|
56
|
-
if (inflight)
|
|
57
|
-
return inflight;
|
|
89
|
+
async function loadDb() {
|
|
90
|
+
if (fresh(db))
|
|
91
|
+
return db;
|
|
92
|
+
if (inFlight)
|
|
93
|
+
return inFlight;
|
|
58
94
|
const p = (async () => {
|
|
59
95
|
try {
|
|
60
|
-
const res = await fetch(
|
|
61
|
-
headers:
|
|
62
|
-
|
|
63
|
-
...providerHeaders(baseUrl), // OpenRouter app attribution
|
|
64
|
-
},
|
|
65
|
-
// boot-time calls happen per agent; a slow/unroutable endpoint used to
|
|
66
|
-
// stall startup for up to 15s PER AGENT before this was tightened
|
|
67
|
-
signal: AbortSignal.timeout(3_000),
|
|
96
|
+
const res = await fetch(DB_URL, {
|
|
97
|
+
headers: providerHeaders(DB_URL),
|
|
98
|
+
signal: AbortSignal.timeout(8_000),
|
|
68
99
|
});
|
|
69
100
|
if (!res.ok)
|
|
70
101
|
throw new Error(`HTTP ${res.status}`);
|
|
71
102
|
const j = (await res.json());
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
prompt: Number(m.pricing.prompt ?? 0),
|
|
81
|
-
completion: Number(m.pricing.completion ?? 0),
|
|
82
|
-
}
|
|
83
|
-
: undefined,
|
|
84
|
-
/**
|
|
85
|
-
* `modalities` is only known when the provider PUBLISHES it — in practice OpenRouter's
|
|
86
|
-
* `architecture.input_modalities`.
|
|
87
|
-
*
|
|
88
|
-
* Every other endpoint omits the field, so this stays `undefined` and the UI must treat it as
|
|
89
|
-
* UNKNOWN rather than as "text only". Reported as "Command Code GOATのモデルカタログが
|
|
90
|
-
* input/outputのフォーマットを適切に提供していない? OpenRouterしかちゃんと対応できてない可能性は
|
|
91
|
-
* ある" — which is exactly right, and the code was right not to invent a value.
|
|
92
|
-
*
|
|
93
|
-
* The consequence to know about: without a modality list nothing can decide "this model
|
|
94
|
-
* accepts an image", so an operator's screenshot reaches the model as a FILE PATH and an agent
|
|
95
|
-
* that tries `read_file` gets bytes. The guard for that lives in `read_file` (it refuses a
|
|
96
|
-
* binary file by name instead of returning mojibake), so the failure is at least legible.
|
|
97
|
-
*/
|
|
98
|
-
modalities: Array.isArray(m.architecture?.input_modalities) && m.architecture.input_modalities.length
|
|
99
|
-
? {
|
|
100
|
-
input: m.architecture.input_modalities,
|
|
101
|
-
output: m.architecture.output_modalities ?? ["text"],
|
|
102
|
-
}
|
|
103
|
-
: undefined,
|
|
104
|
-
// OpenRouter advertises per-model capabilities; other endpoints omit
|
|
105
|
-
// the field entirely, which leaves support "unknown" rather than false.
|
|
106
|
-
supportedParameters: Array.isArray(m.supported_parameters)
|
|
107
|
-
? m.supported_parameters.filter((p) => typeof p === "string")
|
|
108
|
-
: undefined,
|
|
109
|
-
// per-model reasoning levels when published (OpenRouter): the UI offers
|
|
110
|
-
// exactly these, and the request is clamped into them — offering a level the
|
|
111
|
-
// model does not have is a control that does nothing (#150)
|
|
112
|
-
supportedEfforts: Array.isArray(m.reasoning?.supported_efforts)
|
|
113
|
-
? m.reasoning.supported_efforts.filter((e) => typeof e === "string")
|
|
114
|
-
: undefined,
|
|
115
|
-
...(typeof m.reasoning?.default_effort === "string"
|
|
116
|
-
? { defaultEffort: m.reasoning.default_effort }
|
|
117
|
-
: {}),
|
|
118
|
-
...(m.reasoning?.mandatory === true ? { reasoningMandatory: true } : {}),
|
|
119
|
-
}))
|
|
120
|
-
.filter((m) => m.id);
|
|
121
|
-
cache.set(root, { at: Date.now(), list });
|
|
122
|
-
return list;
|
|
103
|
+
const providers = new Map();
|
|
104
|
+
for (const [key, value] of Object.entries(j)) {
|
|
105
|
+
if (value && typeof value === "object")
|
|
106
|
+
providers.set(key, value);
|
|
107
|
+
}
|
|
108
|
+
const snap = { at: Date.now(), providers };
|
|
109
|
+
db = snap;
|
|
110
|
+
return snap;
|
|
123
111
|
}
|
|
124
112
|
catch {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Remember the FAILURE briefly, and always return a usable snapshot.
|
|
115
|
+
*
|
|
116
|
+
* Without the negative cache every agent paid an 8s timeout at boot — the same shape as the
|
|
117
|
+
* old per-endpoint fetch. An empty map is not an error to the caller: it means "no data", and
|
|
118
|
+
* every consumer treats absent metadata as UNKNOWN rather than as "unsupported".
|
|
119
|
+
*/
|
|
120
|
+
const snap = { at: Date.now(), providers: new Map(), negative: true };
|
|
121
|
+
db = snap;
|
|
122
|
+
return snap;
|
|
129
123
|
}
|
|
130
124
|
})();
|
|
131
|
-
inFlight
|
|
125
|
+
inFlight = p;
|
|
132
126
|
try {
|
|
133
127
|
return await p;
|
|
134
128
|
}
|
|
135
129
|
finally {
|
|
136
|
-
inFlight
|
|
130
|
+
inFlight = undefined;
|
|
137
131
|
}
|
|
138
132
|
}
|
|
133
|
+
/** the effort levels a models.dev record publishes, flattened out of `reasoning_options` */
|
|
134
|
+
export function effortsFrom(m) {
|
|
135
|
+
const values = (m?.reasoning_options ?? [])
|
|
136
|
+
.filter((o) => o?.type === "effort")
|
|
137
|
+
.flatMap((o) => (Array.isArray(o.values) ? o.values : []))
|
|
138
|
+
.filter((v) => typeof v === "string");
|
|
139
|
+
return values.length ? [...new Set(values)] : undefined;
|
|
140
|
+
}
|
|
141
|
+
const strings = (v) => {
|
|
142
|
+
const out = (Array.isArray(v) ? v : []).filter((x) => typeof x === "string");
|
|
143
|
+
return out.length ? out : undefined;
|
|
144
|
+
};
|
|
145
|
+
/** one models.dev record → our shape; unknown fields stay absent, never invented */
|
|
146
|
+
export function metaFrom(m) {
|
|
147
|
+
const input = strings(m.modalities?.input);
|
|
148
|
+
const output = strings(m.modalities?.output);
|
|
149
|
+
const efforts = effortsFrom(m);
|
|
150
|
+
return {
|
|
151
|
+
id: String(m.id ?? ""),
|
|
152
|
+
...(typeof m.name === "string" ? { name: m.name } : {}),
|
|
153
|
+
...(typeof m.limit?.context === "number" ? { contextLength: m.limit.context } : {}),
|
|
154
|
+
...(m.cost && (typeof m.cost.input === "number" || typeof m.cost.output === "number")
|
|
155
|
+
? { pricing: { prompt: Number(m.cost.input ?? 0), completion: Number(m.cost.output ?? 0) } }
|
|
156
|
+
: {}),
|
|
157
|
+
...(input ? { modalities: { input, output: output ?? ["text"] } } : {}),
|
|
158
|
+
...(efforts ? { supportedEfforts: efforts } : {}),
|
|
159
|
+
...(m.reasoning === true ? { reasoning: true } : {}),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/** every model models.dev lists for this baseUrl's provider, or [] when the provider is unknown */
|
|
163
|
+
export async function fetchModelList(baseUrl, _apiKey) {
|
|
164
|
+
const snap = await loadDb();
|
|
165
|
+
const provider = providerFor(snap.providers, baseUrl);
|
|
166
|
+
if (!provider?.models)
|
|
167
|
+
return [];
|
|
168
|
+
return Object.values(provider.models).map(metaFrom).filter((m) => m.id);
|
|
169
|
+
}
|
|
170
|
+
export function peekModelList(baseUrl) {
|
|
171
|
+
if (!fresh(db))
|
|
172
|
+
return undefined;
|
|
173
|
+
const provider = providerFor(db.providers, baseUrl);
|
|
174
|
+
if (!provider?.models)
|
|
175
|
+
return undefined;
|
|
176
|
+
return Object.values(provider.models).map(metaFrom).filter((m) => m.id);
|
|
177
|
+
}
|
|
139
178
|
export function contextLengthFor(list, model) {
|
|
140
179
|
return list.find((m) => m.id === model)?.contextLength;
|
|
141
180
|
}
|
|
142
|
-
/** Reasoning-effort levels OpenRouter accepts (`reasoning_effort`). */
|
|
143
181
|
export const REASONING_EFFORTS = ["minimal", "low", "medium", "high"];
|
|
144
182
|
export function isReasoningEffort(v) {
|
|
145
183
|
return typeof v === "string" && REASONING_EFFORTS.includes(v);
|
|
146
184
|
}
|
|
147
|
-
/** True when the endpoint is OpenRouter (the only one that documents effort). */
|
|
148
|
-
export function isOpenRouter(baseUrl) {
|
|
149
|
-
try {
|
|
150
|
-
return /(^|\.)openrouter\.ai$/i.test(new URL(String(baseUrl ?? "")).host);
|
|
151
|
-
}
|
|
152
|
-
catch {
|
|
153
|
-
return false;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
185
|
/**
|
|
157
|
-
*
|
|
186
|
+
* Should the UI offer a reasoning-effort control for this model?
|
|
158
187
|
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* setting means "let the provider apply its own default".
|
|
188
|
+
* THREE answers, not two. `true` when models.dev says the model reasons; `false` when it says it
|
|
189
|
+
* does not; and TRUE when the model is simply not in the database — because the previous rule
|
|
190
|
+
* ("only OpenRouter") hid the control for every other provider even though the request would have
|
|
191
|
+
* worked. A wire that rejects `reasoning_effort` is recovered from at runtime
|
|
192
|
+
* (`isEffortUnsupported`), so an optimistic default costs one retry at worst, while a pessimistic one
|
|
193
|
+
* silently removes a feature the model does have.
|
|
166
194
|
*/
|
|
167
|
-
export function
|
|
168
|
-
if (!isReasoningEffort(opts.effort))
|
|
169
|
-
return undefined;
|
|
170
|
-
if (!isOpenRouter(opts.baseUrl))
|
|
171
|
-
return undefined;
|
|
195
|
+
export function effortSupportedFor(opts) {
|
|
172
196
|
const meta = opts.list?.find((m) => m.id === opts.model);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
return opts.
|
|
197
|
+
if (!meta)
|
|
198
|
+
return true; // not in models.dev → unknown, not "unsupported"
|
|
199
|
+
return meta.reasoning === true || !!meta.supportedEfforts?.length;
|
|
200
|
+
}
|
|
201
|
+
/** the effort levels to offer, or undefined to let the caller use its default ladder */
|
|
202
|
+
export function effortsFor(opts) {
|
|
203
|
+
return opts.list?.find((m) => m.id === opts.model)?.supportedEfforts;
|
|
180
204
|
}
|
package/dist/scheduler/cron.js
CHANGED
|
@@ -1,20 +1,13 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal 5-field cron matcher (minute hour day-of-month month day-of-week).
|
|
3
|
-
* Also supports shorthand "every <n>s|m|h".
|
|
4
|
-
* The master ticks once every 15s; matching is a handful of integer compares.
|
|
5
|
-
*/
|
|
6
1
|
const BOUNDS = [
|
|
7
2
|
[0, 59],
|
|
8
3
|
[0, 23],
|
|
9
4
|
[1, 31],
|
|
10
5
|
[1, 12],
|
|
11
|
-
[0, 7],
|
|
6
|
+
[0, 7],
|
|
12
7
|
];
|
|
13
8
|
function parseField(part, min, max) {
|
|
14
9
|
const out = new Set();
|
|
15
10
|
const clampOrThrow = (v) => {
|
|
16
|
-
// accepting an out-of-range value produces a set that never matches, i.e. a
|
|
17
|
-
// cron that silently never fires ("99 * * * *")
|
|
18
11
|
if (!Number.isInteger(v) || v < min || v > max)
|
|
19
12
|
throw new Error(`bad cron field: ${part} (${min}-${max} expected)`);
|
|
20
13
|
return v;
|
|
@@ -73,12 +66,10 @@ export function parseSchedule(spec) {
|
|
|
73
66
|
fields: parts.map((p, i) => parseField(p, BOUNDS[i][0], BOUNDS[i][1])),
|
|
74
67
|
};
|
|
75
68
|
}
|
|
76
|
-
/** Does this schedule fire at the given Date? */
|
|
77
69
|
export function matches(schedule, d) {
|
|
78
70
|
const values = [d.getMinutes(), d.getHours(), d.getDate(), d.getMonth() + 1, d.getDay()];
|
|
79
71
|
for (let i = 0; i < 5; i++) {
|
|
80
72
|
if (!schedule.fields[i].has(values[i])) {
|
|
81
|
-
// Sunday alias: field 5 with 7 should also match day=0
|
|
82
73
|
if (i === 4 && values[i] === 0 && schedule.fields[i].has(7))
|
|
83
74
|
continue;
|
|
84
75
|
return false;
|
|
@@ -86,14 +77,8 @@ export function matches(schedule, d) {
|
|
|
86
77
|
}
|
|
87
78
|
return true;
|
|
88
79
|
}
|
|
89
|
-
/**
|
|
90
|
-
* Next fire time after `from`, scanning minute by minute (cheap: a handful of
|
|
91
|
-
* integer compares per minute). The horizon is a year so an annual cron (a
|
|
92
|
-
* December date seen in August) still resolves; a Feb-29-only cron can
|
|
93
|
-
* legitimately report null most of the year.
|
|
94
|
-
*/
|
|
95
80
|
export function nextFireAt(schedule, from = new Date()) {
|
|
96
|
-
const startMinute = Math.floor(from.getTime() / 60_000) + 1;
|
|
81
|
+
const startMinute = Math.floor(from.getTime() / 60_000) + 1;
|
|
97
82
|
const horizon = 60 * 24 * 366;
|
|
98
83
|
for (let i = 0; i < horizon; i++) {
|
|
99
84
|
const d = new Date((startMinute + i) * 60_000);
|