teapot-coding-agent 0.37.76 → 0.37.78

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,4 +1,4 @@
1
- import { closeSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs";
1
+ import { closeSync, openSync, readSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  export const sessionTitles = {
4
4
  order: 30,
@@ -7,6 +7,7 @@ export const sessionTitles = {
7
7
  run(ctx) {
8
8
  const raw = ctx.raw();
9
9
  const agents = Array.isArray(raw.agents) ? raw.agents : [];
10
+ const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
10
11
  let changed = false;
11
12
  /**
12
13
  * Move the title out of `config.json` and into the session's own `session.json`.
@@ -15,32 +16,27 @@ export const sessionTitles = {
15
16
  * and the copy that went stale. A title already present wins over a derived one, so an operator's
16
17
  * rename is never replaced by the auto-generated name.
17
18
  */
19
+ /**
20
+ * Move `agents[].sessionTitle` into the `sessionTitles` MAP, keyed by session id.
21
+ *
22
+ * Per-agent was the wrong key: an agent moves between sessions, so the field had to be cleared on
23
+ * every switch (which reverted a rename), and a session no agent points at could not keep a name.
24
+ * A map also keeps the left panel to ONE config read instead of a `session.json` per directory —
25
+ * MEASURED: 838 sessions = 13.2ms of directory reads vs 1.2ms for the config file.
26
+ */
27
+ const titles = (raw.sessionTitles = isRecord(raw.sessionTitles) ? raw.sessionTitles : {});
18
28
  for (const a of agents) {
19
29
  const session = typeof a.session === "string" ? a.session : "";
20
- if (!session)
21
- continue;
22
- if (session.includes("/") || session.includes(".."))
23
- continue;
24
- const dir = path.join(ctx.dataDir, "sessions", session);
25
- const file = path.join(dir, "session.json");
26
- let manifest = { v: 1 };
27
- try {
28
- manifest = JSON.parse(readFileSync(file, "utf8"));
29
- }
30
- catch {
31
- }
32
- const already = typeof manifest.title === "string" ? manifest.title.trim() : "";
33
- if (!already) {
34
- const fromConfig = typeof a.sessionTitle === "string" ? a.sessionTitle.trim() : "";
35
- const derived = fromConfig || firstUserPromptText(path.join(dir, "chat.jsonl")) || session;
36
- manifest.title = derived;
37
- try {
38
- mkdirSync(dir, { recursive: true });
39
- writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
30
+ const legacy = typeof a.sessionTitle === "string" ? a.sessionTitle.trim() : "";
31
+ if (session && !session.includes("/") && !session.includes("..")) {
32
+ const already = typeof titles[session] === "string" ? String(titles[session]).trim() : "";
33
+ if (!already) {
34
+ const derived = legacy ||
35
+ firstUserPromptText(path.join(ctx.dataDir, "sessions", session, "chat.jsonl")) ||
36
+ session;
37
+ titles[session] = derived;
38
+ changed = true;
40
39
  }
41
- catch {
42
- }
43
- changed = true;
44
40
  }
45
41
  /**
46
42
  * Only report a change when one happened: a migration that claims success every boot rewrites
@@ -51,6 +47,8 @@ export const sessionTitles = {
51
47
  changed = true;
52
48
  }
53
49
  }
50
+ if (Object.keys(titles).length === 0 && raw.sessionTitles !== undefined)
51
+ delete raw.sessionTitles;
54
52
  if (changed)
55
53
  ctx.touch();
56
54
  return changed;
@@ -173,13 +173,87 @@ export function metaFrom(m) {
173
173
  ...(m.reasoning === true ? { reasoning: true } : {}),
174
174
  };
175
175
  }
176
- /** every model models.dev lists for this baseUrl's provider, or [] when the provider is unknown */
177
- export async function fetchModelList(baseUrl, _apiKey) {
178
- const snap = await loadDb();
176
+ /**
177
+ * The model list for a provider: the PROVIDER's own catalogue, enriched by models.dev.
178
+ *
179
+ * The provider is the authority on which models exist — models.dev lags and omits entries. MEASURED:
180
+ * `reflection/beam:free` is served by Command Code (94 models) and absent from models.dev's
181
+ * `commandcode` record (72), so a models.dev-only list silently drops models the operator can
182
+ * actually call.
183
+ *
184
+ * models.dev is still needed for CAPABILITY, because most providers publish none: Command Code's
185
+ * `/models` returns only `{context_length, created, id, name, object, owned_by, supported_endpoints}`
186
+ * while OpenRouter publishes `architecture` (modalities) and `supported_parameters`. So:
187
+ *
188
+ * - provider list wins for MEMBERSHIP;
189
+ * - a provider field, when present, wins for that field;
190
+ * - models.dev fills only what the provider did not say.
191
+ *
192
+ * When the provider cannot be reached at all we fall back to the models.dev list rather than showing
193
+ * nothing.
194
+ */
195
+ export async function fetchModelList(baseUrl, apiKey) {
196
+ const [snap, upstream] = await Promise.all([
197
+ loadDb(),
198
+ fetchProviderModels(baseUrl, apiKey).catch(() => null),
199
+ ]);
179
200
  const provider = providerFor(snap.providers, baseUrl);
180
- if (!provider?.models)
181
- return [];
182
- return Object.values(provider.models).map(metaFrom).filter((m) => m.id);
201
+ const dbModels = provider?.models ? Object.values(provider.models).map(metaFrom).filter((m) => m.id) : [];
202
+ if (!upstream)
203
+ return dbModels;
204
+ const byId = new Map(dbModels.map((m) => [m.id, m]));
205
+ return upstream
206
+ .map((m) => mergeMeta(m, byId.get(m.id)))
207
+ .sort((a, b) => a.id.localeCompare(b.id));
208
+ }
209
+ /** a provider's own `/models`, normalised, or null when it does not answer usefully */
210
+ async function fetchProviderModels(baseUrl, apiKey) {
211
+ const url = `${String(baseUrl).replace(/\/+$/, "")}/models`;
212
+ const res = await fetch(url, {
213
+ headers: { ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}), ...providerHeaders(baseUrl) },
214
+ signal: AbortSignal.timeout(15_000),
215
+ });
216
+ if (!res.ok)
217
+ return null;
218
+ const j = (await res.json());
219
+ const out = [];
220
+ for (const m of j.data ?? []) {
221
+ if (typeof m.id !== "string" || !m.id)
222
+ continue;
223
+ const input = strings(m.architecture?.input_modalities);
224
+ const output = strings(m.architecture?.output_modalities);
225
+ const ctx = typeof m.context_length === "number" ? m.context_length : undefined;
226
+ // `reasoning` in supported_parameters means the model accepts a reasoning_effort-style control
227
+ const params = strings(m.supported_parameters);
228
+ out.push({
229
+ id: m.id,
230
+ ...(typeof m.name === "string" ? { name: m.name } : {}),
231
+ ...(ctx !== undefined ? { contextLength: ctx } : {}),
232
+ ...(input ? { modalities: { input, output: output ?? ["text"] } } : {}),
233
+ ...(params?.some((p) => p === "reasoning" || p === "reasoning_effort") ? { reasoning: true } : {}),
234
+ });
235
+ }
236
+ return out.length ? out : null;
237
+ }
238
+ /** provider fields win; models.dev fills only the gaps */
239
+ function mergeMeta(own, db) {
240
+ if (!db)
241
+ return own;
242
+ return {
243
+ id: own.id,
244
+ ...(own.name ?? db.name ? { name: own.name ?? db.name } : {}),
245
+ ...(own.contextLength ?? db.contextLength ? { contextLength: own.contextLength ?? db.contextLength } : {}),
246
+ ...(own.modalities ?? db.modalities
247
+ ? { modalities: own.modalities ?? db.modalities }
248
+ : {}),
249
+ /**
250
+ * Efforts come from models.dev even when the provider listed the model: no provider publishes the
251
+ * effort LEVELS, only that reasoning exists. `own.reasoning` still wins for the yes/no question.
252
+ */
253
+ ...(db.supportedEfforts ? { supportedEfforts: db.supportedEfforts } : {}),
254
+ ...(own.reasoning ?? db.reasoning ? { reasoning: own.reasoning ?? db.reasoning } : {}),
255
+ ...(db.pricing ? { pricing: db.pricing } : {}),
256
+ };
183
257
  }
184
258
  export function peekModelList(baseUrl) {
185
259
  if (!fresh(db))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.37.76",
3
+ "version": "0.37.78",
4
4
  "description": "A lightweight, always-on multi-agent harness for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",