@pi-archimedes/session-name 2.11.0 → 2.11.2

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,7 @@ 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. 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. |
44
+ | `reasoning` | string \| null | `minimal` | Thinking level for the title request: `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `off`. Resolved to the nearest level the model supports (warns on change); the option is omitted only when that resolves to `"off"`. `off`/`null` therefore request no thinking but can still send a level on models that exclude it — use `"minimal"` for bounded-small everywhere. Case and stray spaces are tolerated (`" Minimal "` works). Anything unrecognised is ignored in favour of the default and logged. Only supported levels reach the provider: the setting resolves through pi's clamp to the nearest level the model supports. |
45
45
 
46
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.
47
47
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/session-name",
3
- "version": "2.11.0",
3
+ "version": "2.11.2",
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.11.0"
21
+ "@pi-archimedes/core": "2.11.2"
22
22
  },
23
23
  "peerDependencies": {
24
24
  "@earendil-works/pi-coding-agent": ">=0.86.0",
package/src/index.test.ts CHANGED
@@ -40,7 +40,9 @@ function createMockCtx(options: {
40
40
  },
41
41
  ];
42
42
 
43
- const defaultModel = { provider: "anthropic", id: "claude-3-5-sonnet" };
43
+ // Reasoning model without a level map: every named level passes clamp
44
+ // through, so existing assertions on forwarded levels keep holding.
45
+ const defaultModel = { provider: "anthropic", id: "claude-3-5-sonnet", reasoning: true };
44
46
 
45
47
  return {
46
48
  sessionManager: {
@@ -132,8 +134,10 @@ describe("generateTitle", () => {
132
134
  // anything. A non-level must never reach the provider — see the pin at the
133
135
  // end of this block for what that costs.
134
136
  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
+ it("passes every supported thinking level through untouched", async () => {
138
+ // The mock model has no level map, like the real title model — so only
139
+ // minimal..high pass through; xhigh/max step down (covered below).
140
+ for (const reasoning of ["minimal", "low", "medium", "high"]) {
137
141
  vi.mocked(loadConfig).mockReturnValueOnce({ reasoning });
138
142
  const ctx = createMockCtx({});
139
143
  await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
@@ -143,7 +147,19 @@ describe("generateTitle", () => {
143
147
  }
144
148
  });
145
149
 
146
- it.each(["Minimal", " high ", "MAX"])("tolerates casing and padding: %s", async (raw) => {
150
+ it.each([["xhigh", "high"], ["MAX", "high"]])("clamps %s to nearest supported %s, warning", async (raw, clamped) => {
151
+ vi.mocked(loadConfig).mockReturnValueOnce({ reasoning: raw });
152
+ const ctx = createMockCtx({});
153
+ clearDiag();
154
+ await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
155
+ expect((ctx.modelRegistry.streamSimple as any).mock.calls[0][2].reasoning).toBe(clamped);
156
+ expect(diag).toHaveBeenCalledWith(
157
+ "session-name",
158
+ expect.stringContaining(`using "${clamped}"`),
159
+ );
160
+ });
161
+
162
+ it.each(["Minimal", " high "])("tolerates casing and padding: %s", async (raw) => {
147
163
  vi.mocked(loadConfig).mockReturnValueOnce({ reasoning: raw });
148
164
  const ctx = createMockCtx({});
149
165
  clearDiag();
@@ -154,7 +170,7 @@ describe("generateTitle", () => {
154
170
  expect(diag).not.toHaveBeenCalled();
155
171
  });
156
172
 
157
- it.each([["ultra"], ["off"], [""], [42], [true], [{}], [[]]])(
173
+ it.each([["ultra"], [""], [42], [true], [{}], [[]]])(
158
174
  "never forwards a non-level: %s",
159
175
  async (raw) => {
160
176
  vi.mocked(loadConfig).mockReturnValueOnce({ reasoning: raw });
@@ -174,8 +190,9 @@ describe("generateTitle", () => {
174
190
  },
175
191
  );
176
192
 
177
- it("sends no reasoning key when the value is null, and says nothing", async () => {
178
- vi.mocked(loadConfig).mockReturnValueOnce({ reasoning: null });
193
+ it.each([["off"], [null]])("omits the key for %s on models that take it", async (reasoning) => {
194
+ // Default mock has no level map, so "off" is supported and omitted silently.
195
+ vi.mocked(loadConfig).mockReturnValueOnce({ reasoning });
179
196
  const ctx = createMockCtx({});
180
197
  clearDiag();
181
198
  await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
@@ -183,6 +200,20 @@ describe("generateTitle", () => {
183
200
  expect(diag).not.toHaveBeenCalled();
184
201
  });
185
202
 
203
+ it("clamps off to minimal with a warning where the model excludes it", async () => {
204
+ // Muse-spark shape: thinking model whose map excludes "off" (null).
205
+ const model = { provider: "op", id: "spark", reasoning: true, thinkingLevelMap: { off: null } };
206
+ vi.mocked(loadConfig).mockReturnValueOnce({ reasoning: "off" });
207
+ const ctx = createMockCtx({ model });
208
+ clearDiag();
209
+ await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
210
+ expect((ctx.modelRegistry.streamSimple as any).mock.calls[0][2].reasoning).toBe("minimal");
211
+ expect(diag).toHaveBeenCalledWith(
212
+ "session-name",
213
+ expect.stringContaining('using "minimal"'),
214
+ );
215
+ });
216
+
186
217
  it("keeps the default when the setting is absent entirely, and says nothing", async () => {
187
218
  // loadConfig merges the package default, so an absent key arrives as
188
219
  // "minimal" — the common path must stay silent. A regression that warned
@@ -202,6 +233,13 @@ describe("generateTitle", () => {
202
233
  expect(diag).not.toHaveBeenCalled();
203
234
  });
204
235
 
236
+ it("accepts off for the call site to clamp per model", () => {
237
+ clearDiag();
238
+ expect(resolveTitleReasoning("off")).toBe("off");
239
+ expect(resolveTitleReasoning(" Off ")).toBe("off");
240
+ expect(diag).not.toHaveBeenCalled();
241
+ });
242
+
205
243
  // Pin the invariant against the arithmetic that makes this dangerous. Of
206
244
  // pi-ai's adapters that export a streamSimple, three never call
207
245
  // `clampThinkingLevel` — anthropic-messages, bedrock-converse-stream, and
@@ -221,15 +259,18 @@ describe("generateTitle", () => {
221
259
  for (const level of levels) {
222
260
  expect(thinkingBudgetForLevel(level)).toBeTypeOf("number");
223
261
  }
224
- const junk: unknown[] = ["ultra", "MINIMAL", "off", "", "Max ", 42, true, {}, []];
262
+ const junk: unknown[] = ["ultra", "MINIMAL", "", "Max ", 42, true, {}, []];
225
263
  for (const value of junk) {
226
264
  expect(thinkingBudgetForLevel(value as never)).toBeUndefined();
227
265
  }
228
266
 
229
- // Whatever the setting holds, the value we forward is always safe to send.
267
+ // Whatever the setting holds, the value reaching clamp is a real level
268
+ // or "off" — never raw input. "off" is resolved per model at the call
269
+ // site (nearest supported, warn on change), never forwarded verbatim.
230
270
  for (const raw of [...levels, ...junk, null, undefined]) {
231
271
  const resolved = resolveTitleReasoning(raw);
232
272
  if (resolved === null) continue; // omitted → no level requested
273
+ if (resolved === "off") continue; // clamped per model at the call site
233
274
  expect(levels).toContain(resolved);
234
275
  expect(thinkingBudgetForLevel(resolved)).toBeTypeOf("number");
235
276
  }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { ThinkingLevel } from "@earendil-works/pi-ai";
2
+ import { clampThinkingLevel } from "@earendil-works/pi-ai";
3
+ import type { ModelThinkingLevel, ThinkingLevel } from "@earendil-works/pi-ai";
3
4
  import { loadConfig } from "@pi-archimedes/core/settings-io";
4
5
  import { diag, describeError } from "@pi-archimedes/core/log";
5
6
  import type { SettingItem } from "@earendil-works/pi-tui";
@@ -10,7 +11,7 @@ export interface SessionNameSettings {
10
11
  // suite-managed by meta's plugin gate (archimedes.sessionName.enabled — see ADR 0012); session-name never reads this
11
12
  enabled?: boolean | undefined;
12
13
  model?: string | undefined;
13
- reasoning?: ThinkingLevel | null | undefined;
14
+ reasoning?: ThinkingLevel | "off" | null | undefined;
14
15
  }
15
16
 
16
17
  const DEFAULT_SESSION_NAME_CONFIG: SessionNameSettings = {
@@ -20,14 +21,16 @@ const DEFAULT_SESSION_NAME_CONFIG: SessionNameSettings = {
20
21
 
21
22
  const NAMESPACE = "archimedes.sessionName";
22
23
 
23
- /** The thinking levels we accept — pi-ai's set, minus `off` (see below). */
24
- const REASONING_LEVELS: readonly ThinkingLevel[] = [
24
+ /** The thinking levels we accept — pi-ai's set, plus `off` (resolved per
25
+ * model at the call site: omitting is the only portable spelling of none). */
26
+ const REASONING_LEVELS: readonly (ThinkingLevel | "off")[] = [
25
27
  "minimal",
26
28
  "low",
27
29
  "medium",
28
30
  "high",
29
31
  "xhigh",
30
32
  "max",
33
+ "off",
31
34
  ];
32
35
 
33
36
  /**
@@ -46,14 +49,15 @@ const REASONING_LEVELS: readonly ThinkingLevel[] = [
46
49
  * budget-based Claude it instead misses the four-key budget table, and the
47
50
  * resulting `undefined` reaches the provider as an invalid `max_tokens`.
48
51
  *
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.
52
+ * Outcomes: a real level (casing/padding tolerated) is returned for the call
53
+ * site to resolve per model; `null` means omit the option entirely; anything
54
+ * else — including absent — is treated like an absent setting and gets the
55
+ * default, with a log line so the user can find the typo. `"off"` is
56
+ * accepted but never forwarded raw: it resolves through pi's
57
+ * `clampThinkingLevel` like every other level (nearest supported, warn on
58
+ * change), because unclamped providers map unknown levels to maximum effort.
55
59
  */
56
- export function resolveTitleReasoning(raw: unknown): ThinkingLevel | null {
60
+ export function resolveTitleReasoning(raw: unknown): ThinkingLevel | "off" | null {
57
61
  if (raw === null) return null;
58
62
  if (typeof raw === "string") {
59
63
  const value = raw.trim().toLowerCase();
@@ -206,8 +210,17 @@ export async function generateTitle(
206
210
  if (!ctx.modelRegistry.hasConfiguredAuth(model)) return;
207
211
 
208
212
  // 5. Stream simple with built-in auth resolution.
209
- // Reasoning is resolved, not forwarded raw: see resolveTitleReasoning().
210
- const reasoning = resolveTitleReasoning(settings.reasoning);
213
+ // Reasoning is resolved per model, not forwarded raw: see
214
+ // resolveTitleReasoning(). `clampThinkingLevel` maps to the nearest level
215
+ // the model supports (warn on change); only an `off` result omits the
216
+ // option, which is the single portable spelling of none.
217
+ const resolved = resolveTitleReasoning(settings.reasoning);
218
+ const requested: ModelThinkingLevel = resolved === null ? "off" : (resolved ?? "minimal");
219
+ const clamped = clampThinkingLevel(model, requested);
220
+ if (clamped !== requested) {
221
+ diag("session-name", `[archimedes] session-name: reasoning "${requested}" is not supported — using "${clamped}"`);
222
+ }
223
+ const reasoning = clamped === "off" ? undefined : clamped;
211
224
  const stream = ctx.modelRegistry.streamSimple(
212
225
  model,
213
226
  {
@@ -220,7 +233,7 @@ export async function generateTitle(
220
233
  ],
221
234
  },
222
235
  {
223
- ...(reasoning !== null ? { reasoning } : {}),
236
+ ...(reasoning !== undefined ? { reasoning } : {}),
224
237
  cacheRetention: "none",
225
238
  sessionId: crypto.randomUUID(),
226
239
  },