@msm-core/mini 0.14.0 → 0.15.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/CHANGELOG.md CHANGED
@@ -12,6 +12,91 @@ Follows [Semantic Versioning](https://semver.org/).
12
12
 
13
13
  ---
14
14
 
15
+ ## [0.15.0] — 2026-09-13
16
+
17
+ Session ت١ (tadween chapter, item 1). Thinking is separated from the answer,
18
+ and text parts are joined — on both paths, for Gemini and Anthropic alike.
19
+ Everything here is additive; a composition that passes no new option gets
20
+ yesterday's behavior, byte for byte.
21
+
22
+ ### Fixed
23
+
24
+ - **Gemini: the first text part was the whole answer.** The brain read its
25
+ reply with `parts.find(text)`, so a candidate of several text parts returned
26
+ the first and dropped the rest — and with thinking enabled the first part
27
+ IS the reasoning, so the user read the model's notes instead of its reply.
28
+ Now every text part NOT tagged `thought: true` is joined in emitted order
29
+ with no separator; tagged parts are joined separately and never enter the
30
+ answer. Untagged prose is kept as written: the loop drops what the provider
31
+ tagged and keeps what it did not.
32
+ - **Gemini streamed: thinking parts leaked into the merged answer.**
33
+ `accumulateGemini` folded every text part — tagged or not — into one slot.
34
+ It now keeps two (answer and thinking, each tagged as it arrived), and
35
+ `geminiDelta` skips tagged parts, so what an `onChunk` watcher hears stays a
36
+ prefix of the answer the payload finally carries (the ب١ covenant). The
37
+ brain decides text-versus-thinking on the folded shape with the same lines
38
+ it uses unstreamed.
39
+ - **Anthropic: the first text block was the whole answer** (measured under
40
+ the same rule, item 8). Thinking blocks were already excluded by their
41
+ type; every `text` block is now joined in order, and a `thinking` block's
42
+ body goes to `thoughts` (unstreamed — the streamed fold does not carry it,
43
+ and this brain does not request thinking).
44
+
45
+ ### Added
46
+
47
+ - `createGeminiBrain({ thinking?: { budget?, includeThoughts? } })` →
48
+ `generationConfig.thinkingConfig = { thinkingBudget, includeThoughts }`,
49
+ each field only when given, and the whole key only when the option is
50
+ present with a value (`thinking: undefined` from a JavaScript host is
51
+ absence). `budget: 0` is a value and switches thinking off. Without the
52
+ option the request carries no `generationConfig` key at all — guarded by a
53
+ before/after snapshot of the request. The option type is exported as
54
+ `GeminiBrainOptions`. Note: `@google/generative-ai` 0.24.1 (installed, and
55
+ the registry's newest as of 2026-09-13) does not declare `thinkingConfig`;
56
+ it serializes the request verbatim, so the field reaches the wire.
57
+ - `BrainPayload.thoughts?: string` — the model's reasoning for that call,
58
+ joined, absent when there was none (no key, never `""`). Filled by the
59
+ Gemini and Anthropic brains; a custom brain may fill it the same way.
60
+ - `LoopOutcome.thoughts?: string` — the LAST step's `thoughts`, at every exit
61
+ that takes its `text` from a payload (final answer, forced finalize,
62
+ force-respond/escalate). Absent otherwise. **Not written to the session
63
+ log**: the model never sees its own thinking again, so the invariant "what
64
+ the model sees is recorded" does not reach it — auditing it as an event is
65
+ raised to management, not decided here.
66
+ - **`thoughts` is ungated audit material — do not hand it to an end user
67
+ without the host's own filter.** The output gate validates `text` and
68
+ never reads `thoughts`. When the gate BLOCKS the answer (`type:
69
+ "suppressed"`) the outcome carries no `thoughts` either — what could not
70
+ go out as an answer does not go out as notes. On every other verdict
71
+ (`release`, `review`, no validator, or a gate that never ran because the
72
+ text was empty) `thoughts` passes exactly as the model wrote it.
73
+ - **Only with text the model wrote.** On a guard exit that falls back to
74
+ the canned "I was unable to complete…" line, `thoughts` is absent — the
75
+ thinking accompanies the answer it produced, never a sentence the model
76
+ did not write.
77
+ - **Reaches the `replay` tape.** `createRecordingBrain` records the whole
78
+ payload, `thoughts` included, like any other field; the `redact` option
79
+ of `createRecordingBrain` is where a host hides it (and replays with the
80
+ same redactor).
81
+ - `## Brain` reads `thinking.budget` and `thinking.includeThoughts` (flat
82
+ dotted keys) into `AgentDefinition.brain.thinking?: { budget?, includeThoughts? }`
83
+ — now an explicit field — and `buildBrain` hands it to `createGeminiBrain`
84
+ **after checking its shape**: an object; `budget` a finite number ≥ 0 when
85
+ given; `includeThoughts` a boolean when given. A value that does not fit is
86
+ a **named error** (from the parser for text, from `buildBrain` for a
87
+ hand-built definition), never a silent default. Neither key written → no
88
+ `thinking` on the definition and no `generationConfig` on the wire. Only the
89
+ Gemini brain consumes the key in this release. (`## Brain` alone grew the
90
+ dotted grammar; other sections parse exactly as before.)
91
+ - `thinking: {}` — and an object whose fields are all `undefined` — is
92
+ absence: no `generationConfig` on the wire, no `thinkingConfig: {}`.
93
+ - Guards: `tests/gemini-thoughts.test.ts` (36 tests) and
94
+ `tests/brain-thinking-definition.test.ts` (14 tests) — each rule above, each
95
+ control named as such; the rule guards were shown red by deliberate breaks
96
+ (ten breaks over the two rounds, 21 of the 26 new tests reddened by name; the
97
+ rest are controls) and restored by fingerprint; the loop's three exit sites
98
+ guarded one by one.
99
+
15
100
  ## [0.14.0] — 2026-09-01
16
101
 
17
102
  Session ص٤. The Redis-mandatory era ends.
package/README.md CHANGED
@@ -150,6 +150,60 @@ const outcome = await agent.handle({
150
150
 
151
151
  `buildBrain(definition)` reads the `## Brain` section of an `AgentDefinition` and returns the correct brain automatically.
152
152
 
153
+ ### Thinking (Gemini) — `thinking`
154
+
155
+ Gemini 2.5+ models reason before they answer. Opt in per brain, and the
156
+ reasoning comes back **separated** from the reply rather than inside it:
157
+
158
+ ```typescript
159
+ const brain = createGeminiBrain({
160
+ model: "gemini-2.5-flash",
161
+ thinking: { budget: 1024, includeThoughts: false },
162
+ });
163
+ ```
164
+
165
+ Or from the definition file — the same two fields, written flat under
166
+ `## Brain`, reach the brain through `buildBrain`:
167
+
168
+ ```markdown
169
+ ## Brain
170
+ provider: gemini
171
+ model: gemini-2.5-flash
172
+ thinking.budget: 1024
173
+ thinking.includeThoughts: false
174
+ ```
175
+
176
+ - `budget` → `generationConfig.thinkingConfig.thinkingBudget`; `0` switches
177
+ thinking off on models that think by default.
178
+ - `includeThoughts: true` asks for the reasoning back. Gemini tags those parts
179
+ `thought: true`; the brain joins them into `BrainPayload.thoughts` and the
180
+ loop copies the **last step's** value to `LoopOutcome.thoughts`. They never
181
+ enter `response_text`, are never streamed to `onChunk`, and are not written
182
+ to the session log.
183
+ - **Without the option nothing changes**: the request carries no
184
+ `generationConfig` key at all, and the answer is built exactly as before.
185
+ An empty `thinking: {}` is the same as no option. A value that does not fit
186
+ (`thinking.budget: abc`, a negative budget, a non-boolean `includeThoughts`)
187
+ is a **named error** from the parser or from `buildBrain` — never a silent
188
+ default. Only the Gemini brain reads the key today.
189
+ - **`thoughts` is ungated audit material — not for an end user without your
190
+ own filter.** The output gate (`validator`) checks `text` and never reads
191
+ `thoughts`. When it blocks the answer, `thoughts` is dropped with it; on
192
+ every other verdict — and when the gate never ran because the text was
193
+ empty — the reasoning passes exactly as the model wrote it. It rides only
194
+ with text the model wrote: a guard exit that falls back to the canned
195
+ "unable to complete" line carries no `thoughts`.
196
+ - **It reaches the replay tape.** `@msm-core/replay`'s `createRecordingBrain`
197
+ records the whole payload, `thoughts` included; its `redact` option is where
198
+ to hide it (replay with the same redactor).
199
+
200
+ Two rules hold regardless of the option, for Gemini and Anthropic alike
201
+ (`0.15.0`): every text part/block is **joined in emitted order** (the first is
202
+ no longer taken as the whole answer), and a part the provider **tagged** as
203
+ thinking is kept out of the answer. Prose the provider did *not* tag is kept
204
+ as written — the loop does not guess. A model that narrates its reasoning
205
+ into the answer untagged is a prompt (or budget) matter, not a parsing one.
206
+
153
207
  ---
154
208
 
155
209
  ## HTTP Server
@@ -82,23 +82,41 @@ export function createAnthropicBrain(opts) {
82
82
  params: (block.input ?? {}),
83
83
  });
84
84
  }
85
+ // Text and thinking — the same rule as Gemini's (ت١/٨). EVERY `text`
86
+ // block, in the order Claude emitted them, joined with no separator;
87
+ // `thinking` blocks join separately into `thoughts` and never into the
88
+ // answer. Measured before this: `.find()` took the FIRST text block and
89
+ // dropped every later one, while thinking was already kept out by the
90
+ // type check. A `thinking` body only arrives unstreamed — the streamed
91
+ // fold carries no such field (see `AnthropicMessage`) — and this brain
92
+ // never asks for thinking, so `thoughts` is a contract, not a promise.
93
+ let text = "";
94
+ let thoughts = "";
95
+ for (const block of content) {
96
+ if (block.type === "text" && typeof block.text === "string") {
97
+ text += block.text;
98
+ }
99
+ else if (block.type === "thinking" && typeof block.thinking === "string") {
100
+ thoughts += block.thinking;
101
+ }
102
+ }
103
+ const thinking = thoughts ? { thoughts } : {};
85
104
  const orchestration = useToolOrchestration(calls, 0.9);
86
105
  if (orchestration) {
87
106
  return {
88
107
  orchestration,
89
108
  usage: { inputTokens, outputTokens },
90
109
  costUsd,
110
+ ...thinking,
91
111
  ...respondingModel(response),
92
112
  };
93
113
  }
94
- // Text
95
- const textBlock = content.find((b) => b.type === "text");
96
- const text = textBlock?.text ?? "";
97
114
  return {
98
115
  generation: { response_text: text },
99
116
  orchestration: { action: "respond", confidence: 0.95 },
100
117
  usage: { inputTokens, outputTokens },
101
118
  costUsd,
119
+ ...thinking,
102
120
  // Who answered, off the message itself — Claude names the model that
103
121
  // served the request, which is not always the alias that was asked for.
104
122
  // Streamed or not, `response` is the same shape: `message_start`
@@ -13,11 +13,64 @@ export function buildBrain(def) {
13
13
  return createOpenAIBrain({ ...(model ? { model } : {}), ...(endpoint ? { baseURL: endpoint } : {}), ...(apiKey ? { apiKey } : {}) });
14
14
  case "anthropic":
15
15
  return createAnthropicBrain({ ...(model ? { model } : {}), ...(apiKey ? { apiKey } : {}) });
16
- case "gemini":
17
- return createGeminiBrain({ ...(model ? { model } : {}), ...(apiKey ? { apiKey } : {}) });
16
+ case "gemini": {
17
+ // `## Brain` `thinking` (ت١/٢): the definition's key reaches the brain
18
+ // here, and only after its shape is checked — a definition is data from
19
+ // a file or a host, and `[key: string]: unknown` lets anything through.
20
+ const thinking = brainThinkingOf(def.brain.thinking);
21
+ return createGeminiBrain({
22
+ ...(model ? { model } : {}),
23
+ ...(apiKey ? { apiKey } : {}),
24
+ ...(thinking ? { thinking } : {}),
25
+ });
26
+ }
18
27
  case "ollama":
19
28
  return createOllamaBrain({ ...(endpoint ? { endpoint } : {}), ...(model ? { model } : {}) });
20
29
  default:
21
30
  throw new Error(`msm-mini: unknown brain provider "${String(provider)}". Pass a Brain instance directly to createAgent().`);
22
31
  }
23
32
  }
33
+ /**
34
+ * `def.brain.thinking` checked into the option `createGeminiBrain` takes, or
35
+ * `undefined` when the definition has none.
36
+ *
37
+ * A NAMED error for a wrong shape, not silence: `thinking` was added so a
38
+ * definition could switch the model's reasoning on, and a value that quietly
39
+ * did nothing would be the same leak with a config line under it. An object;
40
+ * `budget` a finite number ≥ 0 when given (`0` is thinking OFF); `includeThoughts`
41
+ * a boolean when given. Each field is copied only when set, so `{}` — and a
42
+ * host that spreads `{ budget: undefined }` — reaches the brain as an empty
43
+ * option, which the brain reads as absence.
44
+ */
45
+ function brainThinkingOf(value) {
46
+ if (value === undefined)
47
+ return undefined;
48
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
49
+ throw new Error(`msm-mini: brain.thinking must be an object { budget?, includeThoughts? }, got ${describe(value)}`);
50
+ }
51
+ // `in` narrows `object` to the key it names — no cast over the definition.
52
+ const budget = "budget" in value ? value.budget : undefined;
53
+ const includeThoughts = "includeThoughts" in value ? value.includeThoughts : undefined;
54
+ if (budget !== undefined && !(typeof budget === "number" && Number.isFinite(budget) && budget >= 0)) {
55
+ throw new Error(`msm-mini: brain.thinking.budget must be a finite number >= 0, got ${describe(budget)}`);
56
+ }
57
+ if (includeThoughts !== undefined && typeof includeThoughts !== "boolean") {
58
+ throw new Error(`msm-mini: brain.thinking.includeThoughts must be a boolean, got ${describe(includeThoughts)}`);
59
+ }
60
+ return {
61
+ ...(budget !== undefined ? { budget } : {}),
62
+ ...(includeThoughts !== undefined ? { includeThoughts } : {}),
63
+ };
64
+ }
65
+ /** A value as an error message shows it: `"abc"`, `null`, `an array`. */
66
+ function describe(value) {
67
+ if (value === null)
68
+ return "null";
69
+ if (Array.isArray(value))
70
+ return "an array";
71
+ if (typeof value === "string")
72
+ return JSON.stringify(value);
73
+ if (typeof value === "object")
74
+ return "an object";
75
+ return String(value);
76
+ }
@@ -3,7 +3,23 @@
3
3
  * Peer dependency: @google/generative-ai >= 0.14.0
4
4
  */
5
5
  import type { Brain } from "./types.js";
6
- export declare function createGeminiBrain(opts: {
6
+ export interface GeminiBrainOptions {
7
7
  apiKey?: string;
8
8
  model?: string;
9
- }): Brain;
9
+ /**
10
+ * Thinking, opted into (ت١/٤). Translated to `generationConfig.thinkingConfig`
11
+ * — `budget` → `thinkingBudget`, `includeThoughts` → `includeThoughts`, each
12
+ * only when given — and ONLY when at least one field is given: absent,
13
+ * explicitly `undefined` from a JavaScript host, or an empty `{}`, the
14
+ * request carries no `generationConfig` at all and not one byte of it changes.
15
+ *
16
+ * `budget: 0` turns thinking off on models that allow it; `includeThoughts:
17
+ * true` asks for the reasoning back as parts tagged `thought: true`, which
18
+ * the brain routes to `BrainPayload.thoughts` and never into the answer.
19
+ */
20
+ thinking?: {
21
+ budget?: number;
22
+ includeThoughts?: boolean;
23
+ };
24
+ }
25
+ export declare function createGeminiBrain(opts: GeminiBrainOptions): Brain;
@@ -9,6 +9,9 @@ import { accumulateGemini, consumeStream, geminiDelta, } from "./streaming.js";
9
9
  import { computeCostUsd } from "./pricing.js";
10
10
  export function createGeminiBrain(opts) {
11
11
  const model = opts.model ?? "gemini-2.5-flash";
12
+ // Resolved once, at composition: the request below spreads it, so a brain
13
+ // built without the option builds the request it always built.
14
+ const thinkingConfig = thinkingConfigOf(opts.thinking);
12
15
  return {
13
16
  name: "gemini",
14
17
  async run(input) {
@@ -55,13 +58,19 @@ export function createGeminiBrain(opts) {
55
58
  },
56
59
  ]
57
60
  : undefined;
58
- const request = tools
59
- ? {
60
- systemInstruction: input.system_context,
61
- contents,
62
- tools: tools,
63
- }
64
- : { systemInstruction: input.system_context, contents };
61
+ const request = {
62
+ systemInstruction: input.system_context,
63
+ contents,
64
+ ...(tools
65
+ ? {
66
+ tools: tools,
67
+ }
68
+ : {}),
69
+ // The one key ت١ adds, and only for a brain that asked for it — a
70
+ // conditional spread, so "no option" leaves no `generationConfig` key
71
+ // (not even an `undefined` one) for the SDK to serialize.
72
+ ...(thinkingConfig ? { generationConfig: { thinkingConfig } } : {}),
73
+ };
65
74
  // ── The one branch streaming adds ──────────────────────────────────
66
75
  //
67
76
  // No `onChunk`, no stream: the call below is the call this brain has
@@ -99,23 +108,46 @@ export function createGeminiBrain(opts) {
99
108
  params: (fc.args ?? {}),
100
109
  });
101
110
  }
111
+ // Text and thinking — EVERY text part, in emitted order, joined with no
112
+ // separator; the parts the model tagged `thought: true` join separately
113
+ // into `thoughts` and never into the answer (ت١). `.find()` took the
114
+ // first text part and dropped the rest, and with thinking on that first
115
+ // part was the reasoning, so the user read the model's notes instead of
116
+ // its reply. Untagged prose is kept as written: what the provider did not
117
+ // mark is answer text, and guessing is the prompt's job, not the loop's.
118
+ //
119
+ // One loop for both shapes — the raw candidate `generateContent` returns
120
+ // and the folded one `accumulateGemini` builds, which keeps its thinking
121
+ // in a part tagged the same way — so the streamed and unstreamed paths
122
+ // decide text-versus-thinking on the same lines.
123
+ let text = "";
124
+ let thoughts = "";
125
+ for (const part of parts) {
126
+ if (typeof part.text !== "string")
127
+ continue;
128
+ if (part.thought === true)
129
+ thoughts += part.text;
130
+ else
131
+ text += part.text;
132
+ }
133
+ // Absent when there was no thinking — a key only when it says something.
134
+ const thinking = thoughts ? { thoughts } : {};
102
135
  const orchestration = useToolOrchestration(calls, 0.9);
103
136
  if (orchestration) {
104
137
  return {
105
138
  orchestration,
106
139
  costUsd,
107
140
  ...(usage ? { usage } : {}),
141
+ ...thinking,
108
142
  ...respondingModel(response),
109
143
  };
110
144
  }
111
- // Text response
112
- const textPart = parts.find((p) => "text" in p && typeof p.text === "string");
113
- const text = textPart && "text" in textPart ? textPart.text : "";
114
145
  return {
115
146
  generation: { response_text: text },
116
147
  orchestration: { action: "respond", confidence: 0.95 },
117
148
  costUsd,
118
149
  ...(usage ? { usage } : {}),
150
+ ...thinking,
119
151
  // Who answered — Gemini spells it `modelVersion`, and it is the field
120
152
  // that tells `gemini-2.5-flash` from the dated build that served it.
121
153
  // The SDK's non-streaming path hands back the parsed response body as
@@ -127,6 +159,33 @@ export function createGeminiBrain(opts) {
127
159
  },
128
160
  };
129
161
  }
162
+ /**
163
+ * `createGeminiBrain({ thinking })` → the wire's `thinkingConfig`, or
164
+ * `undefined` when the option says nothing.
165
+ *
166
+ * Absence is read from the VALUE, not the key (تعميم المجلس ٦): a JavaScript
167
+ * host that spreads `thinking: undefined` gets the request it would get with
168
+ * no option at all. Each field is copied only when set, and `0` is a value —
169
+ * `budget: 0` is how thinking is switched OFF on a model that thinks by
170
+ * default, and a truthiness check would have thrown that away.
171
+ *
172
+ * **An empty object is absence too** (ت١/٢ item 5). `thinking: {}` — or
173
+ * `{ budget: undefined }` from a host that spreads what it does not have —
174
+ * names no field, so nothing goes on the wire: no `generationConfig`, and no
175
+ * `thinkingConfig: {}` that nobody asked for. The key is written only when
176
+ * it carries at least one value.
177
+ */
178
+ function thinkingConfigOf(thinking) {
179
+ if (thinking === undefined)
180
+ return undefined;
181
+ const config = {
182
+ ...(thinking.budget !== undefined ? { thinkingBudget: thinking.budget } : {}),
183
+ ...(thinking.includeThoughts !== undefined
184
+ ? { includeThoughts: thinking.includeThoughts }
185
+ : {}),
186
+ };
187
+ return Object.keys(config).length > 0 ? config : undefined;
188
+ }
130
189
  /**
131
190
  * `generateContentStream` read to the end, folded back into the response shape
132
191
  * the caller above expects.
@@ -179,6 +179,16 @@ export interface AnthropicMessage {
179
179
  text?: string;
180
180
  name?: string;
181
181
  input?: unknown;
182
+ /**
183
+ * The body of a `thinking` block, as the unstreamed API spells it (ت١/٨).
184
+ * The brain joins these into `BrainPayload.thoughts` and never into the
185
+ * text. The streamed fold below does NOT carry it — `thinking_delta` is
186
+ * neither text nor JSON to `accumulateAnthropic`, so a streamed thinking
187
+ * block folds to `{ type: "thinking", text: "" }` as it always has, and
188
+ * `thoughts` is simply absent on that path. Said here so nobody reads the
189
+ * asymmetry as an accident: this brain does not request thinking at all.
190
+ */
191
+ thinking?: string;
182
192
  }>;
183
193
  usage: {
184
194
  input_tokens: number;
@@ -227,6 +237,18 @@ export interface GeminiPart {
227
237
  name: string;
228
238
  args?: unknown;
229
239
  };
240
+ /**
241
+ * `true` on a text part that is the model's THINKING, not its answer (ت١).
242
+ *
243
+ * Gemini 2.5+ marks the parts it emits under `thinkingConfig.includeThoughts`
244
+ * with exactly this flag, on the wire, and `@google/generative-ai` 0.24.1
245
+ * hands the parsed body through without declaring the field — so it is
246
+ * declared here, where the brain reads it, and nowhere else. A part without
247
+ * the flag (or with it `false`) is answer text; ONLY `=== true` is thinking.
248
+ * The brain drops what the provider tagged and keeps what it did not — prose
249
+ * that merely *reads* like reasoning is the prompt's business, not the loop's.
250
+ */
251
+ thought?: boolean;
230
252
  }
231
253
  /** What the Gemini brain reads off a response — streamed or not. */
232
254
  export interface GeminiResponse {
@@ -265,17 +287,28 @@ export declare function geminiDelta(item: GeminiResponse): string;
265
287
  * Fold Gemini stream items into the response they describe.
266
288
  *
267
289
  * **The text parts MERGE into one.** This is the whole reason this function is
268
- * not a flat concatenation of `parts` arrays: the brain finds its answer with
269
- * `parts.find(p => "text" in p)`, so a response left as fifty little text parts
270
- * would return the first syllable of the answer and drop the rest. One part in,
271
- * one part out the same shape `generateContent` returns.
290
+ * not a flat concatenation of `parts` arrays: until ت١ the brain found its
291
+ * answer with `parts.find(p => "text" in p)`, so a response left as fifty
292
+ * little text parts returned the first syllable of the answer and dropped the
293
+ * rest. The brain now JOINS every text part, so the merge is no longer what
294
+ * saves the answer — but one part in, one part out is still the shape
295
+ * `generateContent` returns, and a fold that equals the unstreamed object is
296
+ * the claim the ب١ fixtures check.
272
297
  *
273
298
  * The merged text keeps the POSITION of the first text part it saw, so a
274
299
  * response that opened with a function call still reads in emitted order.
275
300
  * (Order between text and calls does not reach the payload — the brain collects
276
- * calls by filter and text by find — but a shape that reorders parts for no
301
+ * calls by filter and text by join — but a shape that reorders parts for no
277
302
  * reason is a shape that will eventually be believed.)
278
303
  *
304
+ * **Two slots, not one (ت١).** A part the model tagged `thought: true` merges
305
+ * into its OWN part, tagged the same way, and never into the answer's. The
306
+ * folded shape therefore still carries both kinds, tagged as they arrived, and
307
+ * the brain decides text-versus-thinking on that shape with the same lines it
308
+ * uses on an unstreamed candidate — one place for the decision, two paths
309
+ * through it. A stream with no thinking part folds to the object it folded to
310
+ * before the second slot existed, key for key.
311
+ *
279
312
  * Usage is cumulative in Gemini's stream: the last item that carries it wins.
280
313
  */
281
314
  export declare function accumulateGemini(items: readonly GeminiResponse[]): GeminiResponse;
@@ -266,6 +266,11 @@ export function geminiDelta(item) {
266
266
  const parts = item.candidates?.[0]?.content?.parts ?? [];
267
267
  let text = "";
268
268
  for (const part of parts) {
269
+ // A thinking part is not the answer, and what reaches the watcher must
270
+ // stay a prefix of the answer the payload finally carries (ب١) — so it is
271
+ // skipped here for the same reason the brain keeps it out of the text.
272
+ if (part.thought === true)
273
+ continue;
269
274
  if (typeof part.text === "string")
270
275
  text += part.text;
271
276
  }
@@ -275,24 +280,47 @@ export function geminiDelta(item) {
275
280
  * Fold Gemini stream items into the response they describe.
276
281
  *
277
282
  * **The text parts MERGE into one.** This is the whole reason this function is
278
- * not a flat concatenation of `parts` arrays: the brain finds its answer with
279
- * `parts.find(p => "text" in p)`, so a response left as fifty little text parts
280
- * would return the first syllable of the answer and drop the rest. One part in,
281
- * one part out the same shape `generateContent` returns.
283
+ * not a flat concatenation of `parts` arrays: until ت١ the brain found its
284
+ * answer with `parts.find(p => "text" in p)`, so a response left as fifty
285
+ * little text parts returned the first syllable of the answer and dropped the
286
+ * rest. The brain now JOINS every text part, so the merge is no longer what
287
+ * saves the answer — but one part in, one part out is still the shape
288
+ * `generateContent` returns, and a fold that equals the unstreamed object is
289
+ * the claim the ب١ fixtures check.
282
290
  *
283
291
  * The merged text keeps the POSITION of the first text part it saw, so a
284
292
  * response that opened with a function call still reads in emitted order.
285
293
  * (Order between text and calls does not reach the payload — the brain collects
286
- * calls by filter and text by find — but a shape that reorders parts for no
294
+ * calls by filter and text by join — but a shape that reorders parts for no
287
295
  * reason is a shape that will eventually be believed.)
288
296
  *
297
+ * **Two slots, not one (ت١).** A part the model tagged `thought: true` merges
298
+ * into its OWN part, tagged the same way, and never into the answer's. The
299
+ * folded shape therefore still carries both kinds, tagged as they arrived, and
300
+ * the brain decides text-versus-thinking on that shape with the same lines it
301
+ * uses on an unstreamed candidate — one place for the decision, two paths
302
+ * through it. A stream with no thinking part folds to the object it folded to
303
+ * before the second slot existed, key for key.
304
+ *
289
305
  * Usage is cumulative in Gemini's stream: the last item that carries it wins.
290
306
  */
291
307
  export function accumulateGemini(items) {
292
308
  const parts = [];
293
309
  let textSlot = -1;
310
+ let thoughtSlot = -1;
294
311
  let usage;
295
312
  let modelVersion;
313
+ // Append `text` to the part at `slot`, opening it at the end if there is none
314
+ // yet; returns the slot. `thought` rides along so the merged part stays tagged.
315
+ const merge = (slot, text, thought) => {
316
+ if (slot === -1) {
317
+ parts.push(thought ? { text, thought: true } : { text });
318
+ return parts.length - 1;
319
+ }
320
+ const joined = (parts[slot]?.text ?? "") + text;
321
+ parts[slot] = thought ? { text: joined, thought: true } : { text: joined };
322
+ return slot;
323
+ };
296
324
  for (const item of items) {
297
325
  if (item.usageMetadata)
298
326
  usage = item.usageMetadata;
@@ -302,13 +330,10 @@ export function accumulateGemini(items) {
302
330
  modelVersion = item.modelVersion;
303
331
  for (const part of item.candidates?.[0]?.content?.parts ?? []) {
304
332
  if (typeof part.text === "string") {
305
- if (textSlot === -1) {
306
- textSlot = parts.length;
307
- parts.push({ text: part.text });
308
- }
309
- else {
310
- parts[textSlot] = { text: (parts[textSlot]?.text ?? "") + part.text };
311
- }
333
+ if (part.thought === true)
334
+ thoughtSlot = merge(thoughtSlot, part.text, true);
335
+ else
336
+ textSlot = merge(textSlot, part.text, false);
312
337
  }
313
338
  else if (part.functionCall) {
314
339
  parts.push({ functionCall: part.functionCall });
package/dist/core/loop.js CHANGED
@@ -892,6 +892,7 @@ export function createAgent(config) {
892
892
  terminatedBy: hardSignal.type,
893
893
  toolCalls: toolResults,
894
894
  ...(gated.validation ? { validation: gated.validation } : {}),
895
+ ...thoughtsOf(finalPayload, gated.validation),
895
896
  });
896
897
  }
897
898
  }
@@ -900,8 +901,15 @@ export function createAgent(config) {
900
901
  }
901
902
  }
902
903
  // force_respond / escalate → emit last text if available
903
- const lastText = state.lastPayload?.generation?.response_text ??
904
- state.lastPayload?.final_output?.text ??
904
+ //
905
+ // Two sources, told apart on purpose (ت١/٢ item 4): text the model
906
+ // wrote in its last payload, or the canned line below when it wrote
907
+ // none. The last payload's `thoughts` rides out ONLY with the former
908
+ // — thinking accompanies the answer it produced, never a sentence
909
+ // the model did not write.
910
+ const lastWritten = state.lastPayload?.generation?.response_text ??
911
+ state.lastPayload?.final_output?.text;
912
+ const lastText = lastWritten ??
905
913
  "I was unable to complete this request within the allowed limits. Please try a more specific question.";
906
914
  const gatedLast = await gateAndLog(lastText, stepId);
907
915
  const outcomeType = gatedLast.validation?.action === "block"
@@ -918,6 +926,9 @@ export function createAgent(config) {
918
926
  terminatedBy: hardSignal.type,
919
927
  toolCalls: toolResults,
920
928
  ...(gatedLast.validation ? { validation: gatedLast.validation } : {}),
929
+ ...(lastWritten !== undefined
930
+ ? thoughtsOf(state.lastPayload, gatedLast.validation)
931
+ : {}),
921
932
  });
922
933
  }
923
934
  // ── Context build ─────────────────────────────────────
@@ -1080,6 +1091,9 @@ export function createAgent(config) {
1080
1091
  text: finalText,
1081
1092
  toolCalls: toolResults,
1082
1093
  ...(validation ? { validation } : {}),
1094
+ // The last step's thinking rides out with its answer (ت١) — and only
1095
+ // here, not into the log: the model never sees it again.
1096
+ ...thoughtsOf(payload, validation),
1083
1097
  });
1084
1098
  }
1085
1099
  }
@@ -1211,6 +1225,31 @@ async function runWithTimeout(brainCall, budgetMs, controller) {
1211
1225
  clearTimeout(timer);
1212
1226
  }
1213
1227
  }
1228
+ /**
1229
+ * `{ thoughts }` off the payload the outcome's text came from, or `{}` (ت١).
1230
+ *
1231
+ * Spread into the outcome at every site that takes its `text` from a payload,
1232
+ * and nowhere else — the thinking travels with the answer it belongs to. A
1233
+ * conditional spread so an outcome without thinking has no key, not an
1234
+ * `undefined` one that `toEqual` would wave through (تعميم المجلس ٦).
1235
+ *
1236
+ * **A blocked answer takes its thinking with it** (ت١/٢ item 3). The output
1237
+ * gate validates the TEXT; it never reads `thoughts`. So when the gate says
1238
+ * `block` — the outcome is `suppressed` and the text is the canned line — the
1239
+ * reasoning that produced the blocked answer is dropped too: what the gate
1240
+ * would not let out as an answer does not leave as notes. On every other
1241
+ * verdict (`release`, `review`, or no validator, including a gate that never
1242
+ * ran because the text was empty) `thoughts` passes UNGATED — it is audit
1243
+ * material for the host, declared as such on `LoopOutcome.thoughts`, and not
1244
+ * a second answer for an end user.
1245
+ */
1246
+ function thoughtsOf(payload, validation) {
1247
+ if (validation?.action === "block")
1248
+ return {};
1249
+ return typeof payload?.thoughts === "string" && payload.thoughts !== ""
1250
+ ? { thoughts: payload.thoughts }
1251
+ : {};
1252
+ }
1214
1253
  function makeOutcome(type, sessionId, taskId, state, started, extra = {}) {
1215
1254
  return {
1216
1255
  type,
@@ -360,6 +360,23 @@ export interface BrainPayload {
360
360
  * falsehood this field exists to stop telling.
361
361
  */
362
362
  model?: string;
363
+ /**
364
+ * The model's THINKING for this call, separated from its answer (ت١).
365
+ *
366
+ * Filled by a brain whose provider hands reasoning back tagged as such —
367
+ * Gemini's parts marked `thought: true`, Claude's `thinking` blocks — joined
368
+ * in emitted order and kept OUT of `generation.response_text`. Before this
369
+ * field the Gemini brain took the first text part as the answer, and with
370
+ * thinking on, the first text part was the reasoning: the user read the
371
+ * model's notes instead of its reply.
372
+ *
373
+ * **Optional, and absent means absent.** A call with no thinking carries no
374
+ * key at all — never `""` — so `"thoughts" in payload` is the honest test.
375
+ * The loop copies the LAST step's value to `LoopOutcome.thoughts` and does
376
+ * not write it to the session log: it is an output the model never sees
377
+ * again, so the rule "what the model sees is recorded" does not reach it.
378
+ */
379
+ thoughts?: string;
363
380
  [key: string]: unknown;
364
381
  }
365
382
  /**
@@ -480,6 +497,21 @@ export interface LoopOutcome {
480
497
  terminatedBy?: GuardSignalType;
481
498
  /** Output-gate result, when a validator is configured. */
482
499
  validation?: OutputValidation;
500
+ /**
501
+ * The thinking behind the answer, when the last model step reported any
502
+ * (`BrainPayload.thoughts`, ت١). Present only then — a run whose brain never
503
+ * separated thinking carries no key, exactly as before the field existed.
504
+ *
505
+ * **Ungated audit material — not for an end user without the host's
506
+ * filter** (ت١/٢ item 3). The output gate validates `text` and never reads
507
+ * this field. It is DROPPED when the gate blocks the answer (`type:
508
+ * "suppressed"`) — what could not go out as an answer does not go out as
509
+ * notes — and on every other verdict it passes as the model wrote it,
510
+ * including when the gate never ran because the text was empty. It also
511
+ * accompanies only text the model wrote: a guard exit that falls back to
512
+ * the canned "unable to complete" line carries no `thoughts`.
513
+ */
514
+ thoughts?: string;
483
515
  error?: string;
484
516
  }
485
517
  /**
@@ -1002,6 +1034,20 @@ export interface AgentDefinition {
1002
1034
  model: string;
1003
1035
  endpoint?: string;
1004
1036
  apiKey?: string;
1037
+ /**
1038
+ * Thinking, as `## Brain` spells it (ت١/٢): `thinking.budget` and
1039
+ * `thinking.includeThoughts` — flat dotted keys, parsed into this object;
1040
+ * neither key → no `thinking` at all. `buildBrain` hands it to
1041
+ * `createGeminiBrain({ thinking })` after checking its shape (an object;
1042
+ * `budget` a finite number ≥ 0 when given; `includeThoughts` a boolean
1043
+ * when given — anything else is a named error at composition, not
1044
+ * silence). Only the Gemini brain consumes it in this release; the other
1045
+ * providers do not read the key. An empty object is absence.
1046
+ */
1047
+ thinking?: {
1048
+ budget?: number;
1049
+ includeThoughts?: boolean;
1050
+ };
1005
1051
  [key: string]: unknown;
1006
1052
  };
1007
1053
  capabilities: string[];
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * Supported sections: Persona, Brain, Capabilities, Limits, Sections
5
5
  * Stripped: Equipment, Skills, Hours, Memory rules (owned by the app layer)
6
+ *
7
+ * `## Brain` additionally reads `thinking.budget` and `thinking.includeThoughts`
8
+ * (flat dotted keys) into `brain.thinking` (ت١/٢).
6
9
  */
7
10
  import type { AgentDefinition } from "../core/types.js";
8
11
  /**
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * Supported sections: Persona, Brain, Capabilities, Limits, Sections
5
5
  * Stripped: Equipment, Skills, Hours, Memory rules (owned by the app layer)
6
+ *
7
+ * `## Brain` additionally reads `thinking.budget` and `thinking.includeThoughts`
8
+ * (flat dotted keys) into `brain.thinking` (ت١/٢).
6
9
  */
7
10
  import { readFileSync } from "fs";
8
11
  /**
@@ -34,6 +37,7 @@ function parseMd(md) {
34
37
  const personaName = extractKey(persona, "name") ?? extractKey(persona, "Name");
35
38
  const personaStyle = extractKey(persona, "style") ?? extractKey(persona, "Style");
36
39
  const brainEndpoint = brain["endpoint"] ?? brain["Endpoint"];
40
+ const brainThinking = parseBrainThinking(brain);
37
41
  const domain = meta["domain"] ?? meta["Domain"];
38
42
  const language = meta["language"] ?? meta["Language"];
39
43
  return {
@@ -50,6 +54,7 @@ function parseMd(md) {
50
54
  "openai"),
51
55
  model: brain["model"] ?? brain["Model"] ?? "gpt-4o-mini",
52
56
  ...(brainEndpoint !== undefined ? { endpoint: brainEndpoint } : {}),
57
+ ...(brainThinking !== undefined ? { thinking: brainThinking } : {}),
53
58
  },
54
59
  capabilities,
55
60
  limits: {
@@ -107,8 +112,54 @@ function parseSection(lines, sectionName) {
107
112
  }
108
113
  return result;
109
114
  }
115
+ /**
116
+ * `## Brain` accepts DOTTED keys (`thinking.budget: 1024`) on top of the plain
117
+ * ones every section accepts — that is how a nested option is written flat in
118
+ * a definition file (ت١/٢). The other sections keep the plain-key grammar; a
119
+ * dotted line there is ignored exactly as it was.
120
+ */
110
121
  function parseBrainSection(lines) {
111
- return parseSection(lines, "Brain");
122
+ const result = {};
123
+ for (const line of getSectionLines(lines, "Brain")) {
124
+ const m = /^([A-Za-z]+(?:\.[A-Za-z]+)*)\s*:\s*(.+)$/.exec(line.trim());
125
+ if (m && m[1] && m[2])
126
+ result[m[1]] = m[2].trim();
127
+ }
128
+ return result;
129
+ }
130
+ /**
131
+ * `thinking.budget` / `thinking.includeThoughts` → `brain.thinking`, or
132
+ * `undefined` when neither key was written (then the definition has no
133
+ * `thinking` key at all, and `buildBrain` composes yesterday's brain).
134
+ *
135
+ * A value that does not parse is a NAMED error, not a fallback: `limits` may
136
+ * fall back to a default because a default exists, but there is no default
137
+ * thinking budget to fall back to, and a typo that silently switched the
138
+ * feature off is the silence this key was added to end. `budget` must be a
139
+ * finite number ≥ 0 (`0` is a value — thinking OFF); `includeThoughts` must
140
+ * be `true` or `false`.
141
+ */
142
+ function parseBrainThinking(brain) {
143
+ const rawBudget = brain["thinking.budget"] ?? brain["Thinking.budget"];
144
+ const rawInclude = brain["thinking.includeThoughts"] ?? brain["Thinking.includeThoughts"];
145
+ if (rawBudget === undefined && rawInclude === undefined)
146
+ return undefined;
147
+ const thinking = {};
148
+ if (rawBudget !== undefined) {
149
+ const budget = Number(rawBudget);
150
+ if (!Number.isFinite(budget) || budget < 0) {
151
+ throw new Error(`msm-mini: ## Brain thinking.budget must be a finite number >= 0, got "${rawBudget}"`);
152
+ }
153
+ thinking.budget = budget;
154
+ }
155
+ if (rawInclude !== undefined) {
156
+ const lowered = rawInclude.toLowerCase();
157
+ if (lowered !== "true" && lowered !== "false") {
158
+ throw new Error(`msm-mini: ## Brain thinking.includeThoughts must be true or false, got "${rawInclude}"`);
159
+ }
160
+ thinking.includeThoughts = lowered === "true";
161
+ }
162
+ return thinking;
112
163
  }
113
164
  function parseListSection(lines, sectionName) {
114
165
  return getSectionLines(lines, sectionName)
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export { createAgent } from "./core/loop.js";
10
10
  export { createBrainCompactor } from "./core/loop.js";
11
11
  export type { BrainCompactorOptions } from "./core/loop.js";
12
12
  export { createGeminiBrain } from "./brain/gemini.js";
13
+ export type { GeminiBrainOptions } from "./brain/gemini.js";
13
14
  export { createOpenAIBrain } from "./brain/openai.js";
14
15
  export { createAnthropicBrain } from "./brain/anthropic.js";
15
16
  export { createOllamaBrain } from "./brain/ollama.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msm-core/mini",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Portable AI agent execution loop — brain-agnostic, zero embedded databases",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",