@pi-archimedes/session-name 2.10.3 → 2.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.
package/README.md CHANGED
@@ -41,7 +41,9 @@ After installing Pi, choose one installation command above, then `cd` into your
41
41
  | Setting | Type | Default | Description |
42
42
  |---------|------|---------|-------------|
43
43
  | `model` | string | _(current model)_ | Model used for title generation (e.g. `openai/gpt-4o-mini`). Canonical `provider/id`, bare IDs, and thinking-suffix forms are all resolved. Empty = current model. |
44
- | `reasoning` | string \| null | `minimal` | Thinking level for the title request: `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Set `null` to omit the option entirely — that's the fix for a provider that rejects thinking levels outright, which shows up as the naming warning plus a `400 Invalid request parameters` line in the log. Spell it exactly: an unrecognised value isn't validated, and on some providers it means *more* thinking rather than none. |
44
+ | `reasoning` | string \| null | `minimal` | Thinking level for the title request: `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Set `null` to omit the option entirely — that's the fix for a provider that rejects thinking levels outright, which shows up as the naming warning plus a `400 Invalid request parameters` line in the log. Case and stray spaces are tolerated (`" Minimal "` works). Anything unrecognised is ignored in favour of the default and logged as `[archimedes] session-name: ignoring unrecognized reasoning <value>` — it is never sent to the provider, because what an unknown level *means* is provider-dependent: some read it as more thinking, some reject the request, and we'd rather not depend on which. |
45
+
46
+ `model` is also editable in the `/archimedes` panel (**Model for naming** — Enter to type, blank to go back to the current model); `reasoning` is JSON-only.
45
47
 
46
48
  On/off is managed by the suite: toggle via `/plugins` (`archimedes.sessionName.enabled`, default on).
47
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/session-name",
3
- "version": "2.10.3",
3
+ "version": "2.11.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -18,7 +18,7 @@
18
18
  ".": "./src/index.ts"
19
19
  },
20
20
  "dependencies": {
21
- "@pi-archimedes/core": "2.10.3"
21
+ "@pi-archimedes/core": "2.11.0"
22
22
  },
23
23
  "peerDependencies": {
24
24
  "@earendil-works/pi-coding-agent": ">=0.86.0",
@@ -26,9 +26,9 @@
26
26
  "@earendil-works/pi-tui": ">=0.86.0"
27
27
  },
28
28
  "devDependencies": {
29
- "@earendil-works/pi-ai": "^0.87.0",
30
- "@earendil-works/pi-coding-agent": "^0.87.0",
31
- "@earendil-works/pi-tui": "^0.87.0",
29
+ "@earendil-works/pi-ai": "1.2.0",
30
+ "@earendil-works/pi-coding-agent": "1.2.0",
31
+ "@earendil-works/pi-tui": "1.2.0",
32
32
  "typescript": "^6.0.0"
33
33
  },
34
34
  "pi": {
package/src/index.test.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { loadConfig } from "@pi-archimedes/core/settings-io";
2
+ import { diag } from "@pi-archimedes/core/log";
2
3
  import { describe, expect, it, vi } from "vitest";
3
- import { generateTitle, registerSessionName } from "./index.js";
4
+ import { thinkingBudgetForLevel } from "@earendil-works/pi-ai/api/simple-options";
5
+ import { generateTitle, registerSessionName, resolveTitleReasoning } from "./index.js";
4
6
 
5
7
  function createMockPi(sessionName: string | undefined = undefined) {
6
8
  let currentName = sessionName;
@@ -65,6 +67,16 @@ vi.mock("@pi-archimedes/core/settings-io", () => ({
65
67
  loadConfig: vi.fn((_namespace, defaults) => defaults),
66
68
  }));
67
69
 
70
+ // Diagnostics never go to the console (see packages/core/src/log.ts): a write
71
+ // there smears a fullscreen frame and is discarded inside a subagent child. So
72
+ // every "it said something / it said nothing" assertion below watches `diag`.
73
+ vi.mock("@pi-archimedes/core/log", async (importOriginal) => {
74
+ const real = await importOriginal<typeof import("@pi-archimedes/core/log")>();
75
+ return { ...real, diag: vi.fn() };
76
+ });
77
+
78
+ const clearDiag = () => vi.mocked(diag).mockClear();
79
+
68
80
  describe("generateTitle", () => {
69
81
  it("generates title, trims quotes and whitespace, and calls pi.setSessionName", async () => {
70
82
  const pi = createMockPi();
@@ -116,6 +128,114 @@ describe("generateTitle", () => {
116
128
  else expect(options.reasoning).toBe(reasoning);
117
129
  });
118
130
 
131
+ // Issue #86: settings.json is hand-edited strict JSON, so the value can be
132
+ // anything. A non-level must never reach the provider — see the pin at the
133
+ // end of this block for what that costs.
134
+ describe("reasoning validation", () => {
135
+ it("passes every real thinking level through untouched", async () => {
136
+ for (const reasoning of ["minimal", "low", "medium", "high", "xhigh", "max"]) {
137
+ vi.mocked(loadConfig).mockReturnValueOnce({ reasoning });
138
+ const ctx = createMockCtx({});
139
+ await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
140
+
141
+ const options = (ctx.modelRegistry.streamSimple as any).mock.calls[0][2];
142
+ expect(options.reasoning).toBe(reasoning);
143
+ }
144
+ });
145
+
146
+ it.each(["Minimal", " high ", "MAX"])("tolerates casing and padding: %s", async (raw) => {
147
+ vi.mocked(loadConfig).mockReturnValueOnce({ reasoning: raw });
148
+ const ctx = createMockCtx({});
149
+ clearDiag();
150
+ await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
151
+ expect((ctx.modelRegistry.streamSimple as any).mock.calls[0][2].reasoning).toBe(
152
+ raw.trim().toLowerCase(),
153
+ );
154
+ expect(diag).not.toHaveBeenCalled();
155
+ });
156
+
157
+ it.each([["ultra"], ["off"], [""], [42], [true], [{}], [[]]])(
158
+ "never forwards a non-level: %s",
159
+ async (raw) => {
160
+ vi.mocked(loadConfig).mockReturnValueOnce({ reasoning: raw });
161
+ const ctx = createMockCtx({});
162
+ clearDiag();
163
+ await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
164
+
165
+ const sent = (ctx.modelRegistry.streamSimple as any).mock.calls[0][2].reasoning;
166
+ // Invalid config behaves like absent config: the documented default.
167
+ expect(sent).toBe("minimal");
168
+ // The typo is invisible unless it is reported, and the report is the
169
+ // single log line naming the rejected value.
170
+ expect(diag).toHaveBeenCalledWith(
171
+ "session-name",
172
+ `[archimedes] session-name: ignoring unrecognized reasoning ${JSON.stringify(raw)}`,
173
+ );
174
+ },
175
+ );
176
+
177
+ it("sends no reasoning key when the value is null, and says nothing", async () => {
178
+ vi.mocked(loadConfig).mockReturnValueOnce({ reasoning: null });
179
+ const ctx = createMockCtx({});
180
+ clearDiag();
181
+ await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
182
+ expect((ctx.modelRegistry.streamSimple as any).mock.calls[0][2]).not.toHaveProperty("reasoning");
183
+ expect(diag).not.toHaveBeenCalled();
184
+ });
185
+
186
+ it("keeps the default when the setting is absent entirely, and says nothing", async () => {
187
+ // loadConfig merges the package default, so an absent key arrives as
188
+ // "minimal" — the common path must stay silent. A regression that warned
189
+ // on every default-configured session would otherwise pass the suite.
190
+ const ctx = createMockCtx({});
191
+ clearDiag();
192
+ await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
193
+ expect((ctx.modelRegistry.streamSimple as any).mock.calls[0][2].reasoning).toBe("minimal");
194
+ expect(diag).not.toHaveBeenCalled();
195
+ });
196
+
197
+ it("treats undefined like absent config, without warning", () => {
198
+ // Direct call: the generateTitle path can't produce this, since
199
+ // loadConfig fills the default in, but the helper is exported.
200
+ clearDiag();
201
+ expect(resolveTitleReasoning(undefined)).toBe("minimal");
202
+ expect(diag).not.toHaveBeenCalled();
203
+ });
204
+
205
+ // Pin the invariant against the arithmetic that makes this dangerous. Of
206
+ // pi-ai's adapters that export a streamSimple, three never call
207
+ // `clampThinkingLevel` — anthropic-messages, bedrock-converse-stream, and
208
+ // pi-messages (Radius) — so a forwarded non-level reaches their level
209
+ // tables verbatim:
210
+ // - adaptive-thinking models: mapThinkingLevelToEffort has no `off` case
211
+ // and its `default:` returns "high" — a typo silently buys maximum
212
+ // effort on a one-line title. This is the real damage: nothing fails.
213
+ // - budget-based models: thinkingBudgetForLevel's table has four keys, so
214
+ // a non-level yields undefined, which propagates into max_tokens as NaN
215
+ // (serialized `null`) and the provider rejects it. Silly, but loud.
216
+ // We can't fix pi's tables, so guarantee we never hand it a non-level:
217
+ // whatever we send must be a level the budget table recognises.
218
+ it("resolved reasoning is always a level the Anthropic budget table knows", () => {
219
+ // The hazard, read from pi-ai itself: these levels have a budget, junk doesn't.
220
+ const levels = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
221
+ for (const level of levels) {
222
+ expect(thinkingBudgetForLevel(level)).toBeTypeOf("number");
223
+ }
224
+ const junk: unknown[] = ["ultra", "MINIMAL", "off", "", "Max ", 42, true, {}, []];
225
+ for (const value of junk) {
226
+ expect(thinkingBudgetForLevel(value as never)).toBeUndefined();
227
+ }
228
+
229
+ // Whatever the setting holds, the value we forward is always safe to send.
230
+ for (const raw of [...levels, ...junk, null, undefined]) {
231
+ const resolved = resolveTitleReasoning(raw);
232
+ if (resolved === null) continue; // omitted → no level requested
233
+ expect(levels).toContain(resolved);
234
+ expect(thinkingBudgetForLevel(resolved)).toBeTypeOf("number");
235
+ }
236
+ });
237
+ });
238
+
119
239
  it("triggers onFailure when streamSimple returns stopReason === 'error'", async () => {
120
240
  const pi = createMockPi();
121
241
  const ctx = createMockCtx({
@@ -143,16 +263,16 @@ describe("generateTitle", () => {
143
263
  pi.getSessionName.mockImplementation(() => { throw new Error(message); });
144
264
  const onSuccess = vi.fn();
145
265
  const onFailure = vi.fn();
146
- const log = vi.spyOn(console, "error").mockImplementation(() => {});
147
- try {
148
- await generateTitle(pi as any, createMockCtx({}) as any, onSuccess, onFailure);
149
- expect(onFailure).toHaveBeenCalledTimes(failures);
150
- expect(log).toHaveBeenCalledTimes(failures);
151
- expect(onSuccess).not.toHaveBeenCalled();
152
- expect(pi.setSessionName).not.toHaveBeenCalled();
153
- } finally {
154
- log.mockRestore();
266
+ clearDiag();
267
+ await generateTitle(pi as any, createMockCtx({}) as any, onSuccess, onFailure);
268
+ expect(onFailure).toHaveBeenCalledTimes(failures);
269
+ // A swallowed failure is only acceptable if it left a trace.
270
+ expect(diag).toHaveBeenCalledTimes(failures);
271
+ if (failures === 1) {
272
+ expect(diag).toHaveBeenCalledWith("session-name", expect.stringContaining(message));
155
273
  }
274
+ expect(onSuccess).not.toHaveBeenCalled();
275
+ expect(pi.setSessionName).not.toHaveBeenCalled();
156
276
  });
157
277
 
158
278
  it("does not call onFailure when streamSimple returns stopReason === 'aborted'", async () => {
@@ -229,29 +349,28 @@ describe("registerSessionName", () => {
229
349
  const error = { stopReason: "error", content: [], errorMessage: "400 boom" };
230
350
  const notify = vi.fn();
231
351
  const ctx = { ...createMockCtx({ streamSimpleResult: error }), hasUI: scenario !== "no UI", ui: { notify } };
232
- const log = vi.spyOn(console, "error").mockImplementation(() => {});
233
- try {
234
- await agentEnd({}, ctx);
235
- await agentEnd({}, ctx);
236
- let finish!: (response: any) => void;
237
- const pending = new Promise((resolve) => { finish = resolve; });
238
- ctx.modelRegistry.streamSimple.mockReturnValueOnce({ result: vi.fn(() => pending) });
239
- await agentEnd({}, ctx);
240
- // Change the session/UI while the third request is still pending.
241
- if (scenario === "named") pi.setSessionName("Manual name");
242
- if (scenario === "stale") pi.getSessionName.mockImplementation(() => { throw new Error("stale context"); });
243
- if (scenario === "stale UI") Object.defineProperty(ctx, "hasUI", { get: () => { throw new Error("stale UI"); } });
244
- if (scenario === "broken UI") notify.mockImplementation(() => { throw new Error("UI failed"); });
245
- finish(error);
246
- await pending;
247
- await agentEnd({}, ctx);
248
- expect(ctx.modelRegistry.streamSimple).toHaveBeenCalledTimes(3);
249
- expect(log).toHaveBeenCalledTimes(3);
250
- expect(log).toHaveBeenCalledWith("[archimedes] session-name failed:", "400 boom");
251
- expect(notify).toHaveBeenCalledTimes(scenario === "UI" || scenario === "broken UI" ? 1 : 0);
252
- if (scenario === "UI") expect(notify).toHaveBeenCalledWith(expect.stringContaining("/name"), "warning");
253
- } finally {
254
- log.mockRestore();
255
- }
352
+ clearDiag();
353
+ await agentEnd({}, ctx);
354
+ await agentEnd({}, ctx);
355
+ let finish!: (response: any) => void;
356
+ const pending = new Promise((resolve) => { finish = resolve; });
357
+ ctx.modelRegistry.streamSimple.mockReturnValueOnce({ result: vi.fn(() => pending) });
358
+ await agentEnd({}, ctx);
359
+ // Change the session/UI while the third request is still pending.
360
+ if (scenario === "named") pi.setSessionName("Manual name");
361
+ if (scenario === "stale") pi.getSessionName.mockImplementation(() => { throw new Error("stale context"); });
362
+ if (scenario === "stale UI") Object.defineProperty(ctx, "hasUI", { get: () => { throw new Error("stale UI"); } });
363
+ if (scenario === "broken UI") notify.mockImplementation(() => { throw new Error("UI failed"); });
364
+ finish(error);
365
+ await pending;
366
+ await agentEnd({}, ctx);
367
+ expect(ctx.modelRegistry.streamSimple).toHaveBeenCalledTimes(3);
368
+ // Every attempt is diagnosable…
369
+ expect(diag).toHaveBeenCalledTimes(3);
370
+ expect(diag).toHaveBeenCalledWith("session-name", "[archimedes] session-name failed: 400 boom");
371
+ // …but the user is told once, at the retry-budget boundary: three lines of
372
+ // console noise (or three notifications) is not a report, it is spam.
373
+ expect(notify).toHaveBeenCalledTimes(scenario === "UI" || scenario === "broken UI" ? 1 : 0);
374
+ if (scenario === "UI") expect(notify).toHaveBeenCalledWith(expect.stringContaining("/name"), "warning");
256
375
  });
257
376
  });
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { ThinkingLevel } from "@earendil-works/pi-ai";
3
3
  import { loadConfig } from "@pi-archimedes/core/settings-io";
4
+ import { diag, describeError } from "@pi-archimedes/core/log";
4
5
  import type { SettingItem } from "@earendil-works/pi-tui";
5
6
 
6
7
  // ── Config ──────────────────────────────────────────────────────────────────
@@ -19,6 +20,55 @@ const DEFAULT_SESSION_NAME_CONFIG: SessionNameSettings = {
19
20
 
20
21
  const NAMESPACE = "archimedes.sessionName";
21
22
 
23
+ /** The thinking levels we accept — pi-ai's set, minus `off` (see below). */
24
+ const REASONING_LEVELS: readonly ThinkingLevel[] = [
25
+ "minimal",
26
+ "low",
27
+ "medium",
28
+ "high",
29
+ "xhigh",
30
+ "max",
31
+ ];
32
+
33
+ /**
34
+ * Decide what reasoning level the title request asks for, given whatever was
35
+ * found in settings.json.
36
+ *
37
+ * `settings.json` is hand-edited strict JSON and `loadConfig` returns untyped
38
+ * JSON, so the declared `ThinkingLevel` type narrows nothing at runtime: a
39
+ * wrong case, a stray space, or a typo all get through. Junk must not be
40
+ * forwarded, because pi cannot rescue it everywhere — of the ten adapters that
41
+ * export a `streamSimple`, three never call `clampThinkingLevel`:
42
+ * `anthropic-messages`, `bedrock-converse-stream`, and `pi-messages` (Radius,
43
+ * which forwards the level verbatim to its backend). On the Anthropic pair an
44
+ * unknown value hits `mapThinkingLevelToEffort`, whose `default:` returns
45
+ * `"high"`: a typo silently buys maximum effort on a one-line title. On
46
+ * budget-based Claude it instead misses the four-key budget table, and the
47
+ * resulting `undefined` reaches the provider as an invalid `max_tokens`.
48
+ *
49
+ * Outcomes: a real level (casing/padding tolerated) is forwarded; `null` means
50
+ * omit the option entirely; anything else — including absent — is treated like
51
+ * an absent setting and gets the default, with a log line so the user can find
52
+ * the typo. `"off"` is deliberately not accepted: it would be a fourth
53
+ * spelling of "unset" next to `null` and an absent key, and pi accepts `off` as
54
+ * no-effort anyway wherever it maps one.
55
+ */
56
+ export function resolveTitleReasoning(raw: unknown): ThinkingLevel | null {
57
+ if (raw === null) return null;
58
+ if (typeof raw === "string") {
59
+ const value = raw.trim().toLowerCase();
60
+ const match = REASONING_LEVELS.find((level) => level === value);
61
+ if (match) return match;
62
+ }
63
+ if (raw !== undefined) {
64
+ // A typo in a hand-edited settings.json is invisible unless reported, but
65
+ // the report is for whoever debugs the config — the log file, not the
66
+ // console (which smears a fullscreen frame and is discarded in a child).
67
+ diag("session-name", `[archimedes] session-name: ignoring unrecognized reasoning ${JSON.stringify(raw)}`);
68
+ }
69
+ return DEFAULT_SESSION_NAME_CONFIG.reasoning ?? null;
70
+ }
71
+
22
72
  export function loadSessionNameConfig(): SessionNameSettings {
23
73
  return loadConfig(NAMESPACE, DEFAULT_SESSION_NAME_CONFIG);
24
74
  }
@@ -155,7 +205,9 @@ export async function generateTitle(
155
205
  // 4. Check auth — skip without incrementing failCount if not configured
156
206
  if (!ctx.modelRegistry.hasConfiguredAuth(model)) return;
157
207
 
158
- // 5. Stream simple with built-in auth resolution
208
+ // 5. Stream simple with built-in auth resolution.
209
+ // Reasoning is resolved, not forwarded raw: see resolveTitleReasoning().
210
+ const reasoning = resolveTitleReasoning(settings.reasoning);
159
211
  const stream = ctx.modelRegistry.streamSimple(
160
212
  model,
161
213
  {
@@ -168,7 +220,7 @@ export async function generateTitle(
168
220
  ],
169
221
  },
170
222
  {
171
- ...(settings.reasoning != null ? { reasoning: settings.reasoning } : {}),
223
+ ...(reasoning !== null ? { reasoning } : {}),
172
224
  cacheRetention: "none",
173
225
  sessionId: crypto.randomUUID(),
174
226
  },
@@ -180,7 +232,9 @@ export async function generateTitle(
180
232
  return;
181
233
  }
182
234
  if (response.stopReason === "error") {
183
- console.error("[archimedes] session-name failed:", response.errorMessage ?? "title request failed");
235
+ // The user's notice is the single one at the retry-budget boundary (see
236
+ // registerSessionName); one line per attempt goes to the log.
237
+ diag("session-name", `[archimedes] session-name failed: ${response.errorMessage ?? "title request failed"}`);
184
238
  onFailure();
185
239
  return;
186
240
  }
@@ -209,7 +263,7 @@ export async function generateTitle(
209
263
  } catch (e) {
210
264
  // Session replacement or print-mode teardown makes an in-flight title irrelevant.
211
265
  if (e instanceof Error && e.message.includes("stale after session replacement")) return;
212
- console.error("[archimedes] session-name failed:", e);
266
+ diag("session-name", `[archimedes] session-name failed: ${describeError(e)}`);
213
267
  onFailure();
214
268
  }
215
269
  }