@gonrocca/nodd 0.1.1 → 0.2.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.
@@ -7,6 +7,7 @@ import {
7
7
  mirrorToActiveProfile,
8
8
  readActiveProfile,
9
9
  readProfiles,
10
+ readThinking,
10
11
  } from "./profiles.ts";
11
12
 
12
13
  const withProfiles = () => ({
@@ -184,3 +185,88 @@ test("mirroring with no active profile changes nothing", () => {
184
185
  const data = { models: { implement: "anthropic/x" }, profiles: {}, activeProfile: null };
185
186
  assert.deepEqual(mirrorToActiveProfile(data), data);
186
187
  });
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Thinking levels travel with the models, and only when they exist
191
+ // ---------------------------------------------------------------------------
192
+ test("a profile reads back the thinking levels it was stored with", () => {
193
+ const result = readProfiles({
194
+ profiles: { fast: { models: { implement: "anthropic/x" }, thinking: { implement: "high" } } },
195
+ });
196
+ assert.deepEqual(result.profiles.fast, {
197
+ models: { implement: "anthropic/x" },
198
+ thinking: { implement: "high" },
199
+ });
200
+ assert.deepEqual(result.defects, []);
201
+ });
202
+
203
+ test("an invalid level is dropped and reported, not written on to frontmatter pi cannot resolve", () => {
204
+ const result = readProfiles({
205
+ profiles: { fast: { models: {}, thinking: { implement: "ultracode", explore: "high" } } },
206
+ });
207
+ assert.deepEqual(result.profiles.fast.thinking, { explore: "high" });
208
+ assert.ok(
209
+ result.defects.some((d) => d.includes("ultracode")),
210
+ `the discarded level must be named: ${JSON.stringify(result.defects)}`,
211
+ );
212
+ });
213
+
214
+ test("a profile with no levels carries no thinking key at all, so the file stays minimal", () => {
215
+ const result = readProfiles({ profiles: { fast: { models: { implement: "anthropic/x" } } } });
216
+ assert.deepEqual(result.profiles.fast, { models: { implement: "anthropic/x" } });
217
+ assert.ok(!("thinking" in result.profiles.fast), "an absent level set must not become an empty object");
218
+ });
219
+
220
+ test("new snapshots the live thinking map into the profile", () => {
221
+ const result = applyProfileCommand(
222
+ { models: { implement: "anthropic/x" }, thinking: { implement: "xhigh" } },
223
+ { kind: "new", name: "fresh" },
224
+ );
225
+ assert.equal(result.ok, true);
226
+ if (result.ok !== true) return;
227
+ assert.deepEqual(result.data.profiles, {
228
+ fresh: { models: { implement: "anthropic/x" }, thinking: { implement: "xhigh" } },
229
+ });
230
+ });
231
+
232
+ test("use applies the profile's levels to the live config, and clears them when it has none", () => {
233
+ const withLevels = applyProfileCommand(
234
+ { profiles: { fast: { models: { implement: "anthropic/x" }, thinking: { implement: "low" } } } },
235
+ { kind: "use", name: "fast" },
236
+ );
237
+ assert.equal(withLevels.ok, true);
238
+ if (withLevels.ok !== true) return;
239
+ assert.deepEqual(withLevels.data.thinking, { implement: "low" });
240
+
241
+ // A profile with no levels must not leave the previous profile's levels
242
+ // applied to its models: that would run a model at an effort nobody chose.
243
+ const without = applyProfileCommand(
244
+ { thinking: { implement: "low" }, profiles: { plain: { models: { implement: "anthropic/y" } } } },
245
+ { kind: "use", name: "plain" },
246
+ );
247
+ assert.equal(without.ok, true);
248
+ if (without.ok !== true) return;
249
+ assert.ok(!("thinking" in without.data), "the stale level set must be dropped, not carried over");
250
+ });
251
+
252
+ test("mirroring carries the live levels into the active profile", () => {
253
+ const mirrored = mirrorToActiveProfile({
254
+ models: { implement: "anthropic/just-edited" },
255
+ thinking: { implement: "medium" },
256
+ profiles: { fast: { models: { implement: "anthropic/old" } } },
257
+ activeProfile: "fast",
258
+ });
259
+ assert.deepEqual((mirrored.profiles as Record<string, unknown>).fast, {
260
+ models: { implement: "anthropic/just-edited" },
261
+ thinking: { implement: "medium" },
262
+ });
263
+ });
264
+
265
+ test("readThinking keeps only real levels, so a hand-edited file cannot inject one", () => {
266
+ assert.deepEqual(readThinking({ thinking: { implement: "high", explore: "max", track: "low" } }), {
267
+ implement: "high",
268
+ track: "low",
269
+ });
270
+ assert.deepEqual(readThinking({}), {});
271
+ assert.deepEqual(readThinking({ thinking: "broken" }), {});
272
+ });
@@ -13,7 +13,17 @@
13
13
  // leave the user staring at a profile that vanished with no explanation, and
14
14
  // throwing would take the whole command down over a hand-edit.
15
15
 
16
- export type Profile = { models: Record<string, string> };
16
+ import { isThinkingLevel, type SlotThinking } from "./thinking.ts";
17
+
18
+ /**
19
+ * A named set of slot assignments, plus the thinking level each one runs at.
20
+ *
21
+ * `thinking` is optional and omitted when empty, never stored as `{}`: the
22
+ * stored file is what the round-trip tests compare, and an empty object would
23
+ * make "this profile sets no levels" and "this profile sets levels, none of them"
24
+ * the same shape on disk.
25
+ */
26
+ export type Profile = { models: Record<string, string>; thinking?: SlotThinking };
17
27
 
18
28
  /** Sub-command verbs, which therefore cannot be profile names. */
19
29
  export const RESERVED_PROFILE_NAMES = ["list", "new", "save", "use", "delete", "rm", "from"] as const;
@@ -45,6 +55,38 @@ function stringMap(value: unknown): Record<string, string> {
45
55
  return out;
46
56
  }
47
57
 
58
+ /** The real levels inside one `thinking` map, dropping everything else. */
59
+ function levelMap(value: unknown): SlotThinking {
60
+ const out: SlotThinking = {};
61
+ for (const [slot, level] of Object.entries(isObject(value) ? value : {})) {
62
+ if (isThinkingLevel(level)) out[slot] = level;
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * The thinking levels of a config object, keeping only real pi effort levels.
69
+ *
70
+ * A level pi cannot resolve is dropped rather than carried: it would reach
71
+ * `nodd-agents.ts` and land in agent frontmatter, failing at the moment the
72
+ * agent is launched instead of here, where the user can still see why.
73
+ */
74
+ export function readThinking(data: Record<string, unknown>): SlotThinking {
75
+ return levelMap(data.thinking);
76
+ }
77
+
78
+ /** Every non-level found under a `thinking` map, for reporting. */
79
+ function badLevels(value: unknown): string[] {
80
+ return Object.entries(isObject(value) ? value : {})
81
+ .filter(([, level]) => !isThinkingLevel(level))
82
+ .map(([slot, level]) => `${slot}=${String(level)}`);
83
+ }
84
+
85
+ /** A profile, with `thinking` present only when it holds something. */
86
+ function profileOf(models: Record<string, string>, thinking: SlotThinking): Profile {
87
+ return Object.keys(thinking).length > 0 ? { models, thinking } : { models };
88
+ }
89
+
48
90
  export type ReadProfiles = { profiles: Record<string, Profile>; defects: string[] };
49
91
 
50
92
  export function readProfiles(data: Record<string, unknown>): ReadProfiles {
@@ -65,7 +107,11 @@ export function readProfiles(data: Record<string, unknown>): ReadProfiles {
65
107
  defects.push(`profile \`${name}\` is not an object and was discarded`);
66
108
  continue;
67
109
  }
68
- profiles[name] = { models: stringMap(value.models) };
110
+ const bad = badLevels(value.thinking);
111
+ if (bad.length > 0) {
112
+ defects.push(`profile \`${name}\` has thinking levels pi cannot resolve, discarded: ${bad.join(", ")}`);
113
+ }
114
+ profiles[name] = profileOf(stringMap(value.models), levelMap(value.thinking));
69
115
  }
70
116
  return { profiles, defects };
71
117
  }
@@ -79,7 +125,22 @@ export function readActiveProfile(data: Record<string, unknown>): string | null
79
125
 
80
126
  /** The flat config as a profile snapshot. */
81
127
  function snapshot(data: Record<string, unknown>): Profile {
82
- return { models: stringMap(data.models) };
128
+ return profileOf(stringMap(data.models), readThinking(data));
129
+ }
130
+
131
+ /**
132
+ * Apply a profile's models and levels to the flat config.
133
+ *
134
+ * A profile with no levels *removes* the key rather than leaving the previous
135
+ * profile's levels behind: a stale level would keep running a model at an effort
136
+ * nobody chose for it, which is the silent-divergence defect this whole command
137
+ * is built to avoid.
138
+ */
139
+ function flatten(data: Record<string, unknown>, profile: Profile): Record<string, unknown> {
140
+ const next: Record<string, unknown> = { ...data, models: { ...profile.models } };
141
+ if (profile.thinking) next.thinking = { ...profile.thinking };
142
+ else delete next.thinking;
143
+ return next;
83
144
  }
84
145
 
85
146
  /**
@@ -138,7 +199,7 @@ export function applyProfileCommand(data: Record<string, unknown>, command: Prof
138
199
  }
139
200
  return {
140
201
  ok: true,
141
- data: { ...data, models: { ...profile.models }, activeProfile: command.name },
202
+ data: { ...flatten(data, profile), activeProfile: command.name },
142
203
  message: `perfil ${command.name} activado`,
143
204
  };
144
205
  }
@@ -22,8 +22,9 @@ export const MECHANISM_PLACEHOLDER = "mecanismo · sin modelo";
22
22
 
23
23
  const GLOBAL_SLOTS = ["default", "orchestrator"] as const;
24
24
 
25
- function isMechanism(step: string): boolean {
26
- return (MECHANISM_STEPS as readonly string[]).includes(step);
25
+ /** A canonical step NODD implements as mechanism, so it gets no slot. */
26
+ export function isMechanismSlot(id: string): boolean {
27
+ return (MECHANISM_STEPS as readonly string[]).includes(id);
27
28
  }
28
29
 
29
30
  /**
@@ -33,7 +34,7 @@ function isMechanism(step: string): boolean {
33
34
  export const SLOT_ROWS: readonly SlotRow[] = Object.freeze([
34
35
  ...GLOBAL_SLOTS.map((id): SlotRow => ({ id, kind: "global", placeholder: "sin asignar" })),
35
36
  ...CANONICAL_STEPS.map((id): SlotRow =>
36
- isMechanism(id)
37
+ isMechanismSlot(id)
37
38
  ? { id, kind: "mechanism", placeholder: MECHANISM_PLACEHOLDER }
38
39
  : { id, kind: "step", placeholder: "sin asignar" },
39
40
  ),
@@ -0,0 +1,14 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { THINKING_LEVELS, isThinkingLevel } from "./thinking.ts";
4
+
5
+ test("the six real pi effort levels are listed in ascending order", () => {
6
+ assert.deepEqual([...THINKING_LEVELS], ["off", "minimal", "low", "medium", "high", "xhigh"]);
7
+ });
8
+
9
+ test("only a real level is a level: no max, no ultracode, no casing games", () => {
10
+ for (const level of THINKING_LEVELS) assert.equal(isThinkingLevel(level), true);
11
+ for (const bad of ["max", "ultracode", "HIGH", "", "extreme", 3, null, undefined, {}]) {
12
+ assert.equal(isThinkingLevel(bad), false, `${JSON.stringify(bad)} is not a pi effort level`);
13
+ }
14
+ });
@@ -0,0 +1,22 @@
1
+ // The thinking level of a slot: pi's reasoning effort, per configured model.
2
+ //
3
+ // Adapted from `zero-models.ts:84-93`. These six strings are the real pi effort
4
+ // levels and the only source of truth for validity here — `max` and `ultracode`
5
+ // are not levels, and a config that names one is rejected rather than passed
6
+ // through to frontmatter pi cannot resolve.
7
+ //
8
+ // The level is not decoration: `extensions/nodd-agents.ts` emits it as the
9
+ // `thinking:` line of every generated agent file, which is what makes choosing
10
+ // one in the picker mean something.
11
+
12
+ /** The six real pi effort levels, ascending. */
13
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
14
+
15
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
16
+
17
+ /** slot -> level. Partial on purpose: an absent slot means no level configured. */
18
+ export type SlotThinking = Record<string, ThinkingLevel>;
19
+
20
+ export function isThinkingLevel(value: unknown): value is ThinkingLevel {
21
+ return typeof value === "string" && (THINKING_LEVELS as readonly string[]).includes(value);
22
+ }