@msm-core/mini 0.9.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
@@ -3,6 +3,370 @@
3
3
  All notable changes are documented here.
4
4
  Follows [Semantic Versioning](https://semver.org/).
5
5
 
6
+ > **Note on the gap (recorded 2026-09-01, session م١).** This ledger stopped at
7
+ > `0.5.2` while six versions shipped — and `0.8.0` and `0.9.0` reached npm with
8
+ > no entry to read. The entries from `0.6.0` down were written afterwards from
9
+ > the session rulings in `docs/SESSIONS.md` and the commits that carried each
10
+ > bump, not from memory; each is dated by that commit. The rule going forward:
11
+ > whoever publishes keeps the ledger complete.
12
+
13
+ ---
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
+
100
+ ## [0.14.0] — 2026-09-01
101
+
102
+ Session ص٤. The Redis-mandatory era ends.
103
+
104
+ ### Changed
105
+
106
+ - `AgentConfig.redis` is **optional**. Present: byte-for-byte today's
107
+ behavior. Absent with all four ports injected (memory, controlBus, lock,
108
+ dedup): the loop runs with no Redis at all. Absent with any port missing:
109
+ `createAgent` fails immediately with an error that names the missing
110
+ port — and only it — and points at the `/adapters` in-memory
111
+ implementations. Absence is read from the VALUE (`=== undefined`), so a
112
+ host spreading `redis: undefined` gets the named error, not a TypeError
113
+ from the internals.
114
+
115
+ ## [0.13.0] — 2026-09-01
116
+
117
+ Session س٦ (both rounds). The loop can now run with no Redis at all — and
118
+ the audit log names the model that actually answered.
119
+
120
+ ### Added
121
+
122
+ - Three injected ports on the س١ pattern: `ControlBusPort`, `RunLockPort`
123
+ (named to avoid `@msm-core/jobs`'s `LockPort` — the reason lives in the
124
+ port's own comment), `DedupPort` — `AgentConfig` accepts them optionally;
125
+ absent means the bundled Redis trio exactly as before. In-memory adapters
126
+ for all three; with all four ports injected the loop measurably issues
127
+ ZERO Redis calls.
128
+ - Model truth: the four brains fill `BrainPayload.model` from the
129
+ provider's own reply — streamed and non-streamed both — and the loop
130
+ writes it to `model_response.data.model` (see `@msm-core/session` 0.3.0
131
+ for the event contract). `respondingModel` exported.
132
+
133
+ ### Notes
134
+
135
+ - "Who injects a port injects its isolation" extends to the three new
136
+ ports, guarded behaviorally with two tenants.
137
+
138
+ ## [0.12.0] — 2026-09-01
139
+
140
+ Session ص٣, entry with the bump (the new ledger guard enforces what
141
+ Circular 2 asked by hand).
142
+
143
+ ### Added
144
+
145
+ - The reset channel reaches `AgentHooks.onChunk`: `ChunkInfo.reset?: true`,
146
+ `fireChunk` spreads it, and a hooks consumer opts in with
147
+ `acceptChunkResets(hook)` (`isResetAwareHook`, `ResetAwareChunkHook`
148
+ exported). ص٢'s covenant one layer up: an undeclared consumer's chunks
149
+ are key-for-key what they always were — reset never reaches anyone who
150
+ did not ask for it.
151
+ - Ledger guards in mini/session/replay/mcp: `package.json.version` must
152
+ equal the CHANGELOG head — no seventh unrecorded release.
153
+
154
+ ### Changed
155
+
156
+ - mcp's spawned-process tests comply with Council Circular 1 (stderr in
157
+ every failure text, named waits, declared readiness) — diagnostics only;
158
+ the 98 tests and their assertions are unchanged.
159
+
160
+ ## [0.11.0] — 2026-09-01
161
+
162
+ Session م١, entry by management at acceptance (Circular 2: the ledger rides
163
+ with the bump, not behind it).
164
+
165
+ ### Added
166
+
167
+ - `loadSkills(dir, log?)` in the definition layer — Claude-style skill packs
168
+ (`skills/<pack>/SKILL.md` folders and single `.md` files) read into one
169
+ `## Skills` markdown block for the host to append to an agent definition.
170
+ Lifted from nisus's `runtime/skills/loader.ts` and hardened: deterministic
171
+ code-unit ordering (platform-independent definitions — replay-stable
172
+ fingerprints), an injected `SkillsLogPort` instead of console, and empty
173
+ skill files contribute nothing rather than a bare separator (a declared,
174
+ parity-tested divergence from the original). Composition stays with the
175
+ host: the code moved, the wiring did not — zero loop changes.
176
+
177
+ ## [0.10.0] — 2026-09-01
178
+
179
+ Session ص٢. A retried stream can finally repair the display it interrupted.
180
+
181
+ ### Added
182
+
183
+ - **`BrainChunk.reset` — a retried call can tell you to clear what you drew.**
184
+ Since 0.9.1 a retried brain call stops re-emitting text you already saw, which
185
+ never truncates the payload but does leave the display frozen at the cut. A
186
+ consumer that *can* clear what it has drawn now opts in **on its sink**:
187
+ `acceptResets(sink)` marks it, `isResetAware(sink)` reports it, and
188
+ `ResetAwareChunkSink` is the type. A marked sink gets `reset: true` on the
189
+ first chunk after a retry and redraws the answer whole. **An unmarked sink is
190
+ on 0.9.1's path byte for byte** — the opt-in is the sink's, so a consumer that
191
+ does nothing sees nothing new.
192
+ - New exports: `acceptResets`, `isResetAware`, type `ResetAwareChunkSink`.
193
+
194
+ ### Changed
195
+
196
+ - Internal, no surface: every package's test tree is now type-checked by `tsc`
197
+ inside `npm test` (17 of 53 test files were under a compiler before; 54 of 54
198
+ now). This is why a type error can no longer sit green for months.
199
+
200
+ ---
201
+
202
+ ## [0.9.1] — 2026-09-01
203
+
204
+ Session ص١ — three internal debts, two of them visible from outside.
205
+
206
+ ### Fixed
207
+
208
+ - **A stamped tool's `destructive` / `category` now reach the model.**
209
+ `toToolDefinitions` declared both fields and dropped them on the way out, so a
210
+ tool marked destructive by the MCP adapter or by `createDelegateTool` was
211
+ described to the provider like any other tool. They are forwarded now.
212
+ - **A retried brain call no longer streams the same words twice.** `withRetry`
213
+ restarted the provider call and the new attempt re-emitted text the consumer
214
+ had already rendered. The gate is keyed on **what was delivered**, not on the
215
+ attempt number: what reaches your `onChunk` is always a prefix of the final
216
+ payload — never longer than it, never contradicting it — and the worst case is
217
+ a display that stops where the cut happened rather than one that repeats or
218
+ truncates mid-sentence. Display only; `LoopOutcome` was always correct.
219
+
220
+ ### Changed
221
+
222
+ - Internal: `mini`'s test tree is type-checked on every `npm test`. Nine
223
+ long-standing type errors in old tests were fixed **types only** — no test's
224
+ behavior and no test count changed.
225
+
226
+ ---
227
+
228
+ ## [0.9.0] — 2026-09-01
229
+
230
+ Session ر٢. Delegation stops being a dead union member and becomes a tool.
231
+
232
+ ### Removed — ⚠️ a type breaks; no runtime does
233
+
234
+ - **`"delegate"` is gone from the `BrainOrchestration.action` union.** It had
235
+ been declared since 0.1.0 and **the loop never executed it** — a brain
236
+ returning it fell through to the unknown-action path. Measured before the
237
+ removal: zero occurrences in `mini/src`, zero in nisus, and none of the four
238
+ brains ever emitted it. If you have a third-party brain that returns
239
+ `action: "delegate"`, it no longer type-checks, and at runtime it behaves
240
+ exactly as it always did: a terminal `respond` that delivers text and cannot
241
+ smuggle a tool call past the dispatcher (now pinned by a guard).
242
+
243
+ ### Added
244
+
245
+ - **`createDelegateTool(delegates, opts?)` — one agent asks another, as an
246
+ ordinary `Tool`.** It returns a plain `call_agent` tool over
247
+ `Record<string, Agent>`, with the delegate names as an `enum` parameter so the
248
+ model cannot invent one. The child's session id is **derived**, not minted:
249
+ `${sessionId}.d.${name}` — deterministic, readable in a log, identical across
250
+ runs. The parent's `tenantContext` is inherited by the child.
251
+ `opts.maxDepth` (default 1) **fails closed** with a named `failed` result
252
+ rather than an exception. The child's cost comes back inside the tool result,
253
+ visible and not swallowed — it is deliberately *not* folded into the parent's
254
+ total, because counting it twice is worse than not counting it.
255
+ `opts.requiresApproval` / `destructive` / `category` stamp the definition, so
256
+ fail-closed approval covers a delegation with no new code.
257
+ - New exports: `createDelegateTool`, `DELEGATE_TOOL_NAME`, types `DelegateTool`,
258
+ `DelegateToolOptions`.
259
+ - **Zero loop changes.** A delegation passes guards, approval, dedup and the
260
+ session log exactly like any other tool — which is the whole argument for a
261
+ tool over an action.
262
+
263
+ ---
264
+
265
+ ## [0.8.0] — 2026-09-01
266
+
267
+ Sessions ض١ and ب١. Two optional seats: one for compaction, one for streaming.
268
+
269
+ ### Added
270
+
271
+ - **Streaming — `BrainRunInput.onChunk` and `AgentHooks.onChunk` (ب١).** All
272
+ four brains stream natively, each behind one branch; `BrainChunk` is
273
+ `{ text }`, and `ChunkInfo` adds `{ sessionId, iteration }` at the hook. Token
274
+ usage still arrives on the streamed path (`stream_options.include_usage`), so
275
+ `costCapPerTask` keeps working while streaming. **Streaming is display, not
276
+ truth**: no stream event is written to the session log, and
277
+ `@msm-core/replay`'s fingerprint is blind to `onChunk` — a run someone watched
278
+ and a run nobody watched are the same run. Without a handler the path is
279
+ literally the previous one, a single `if` apart.
280
+ - **Compaction as an injected seat — `AgentConfig.compaction?: CompactionPort`
281
+ (ض١).** The port decides when a long conversation is summarised;
282
+ `createBrainCompactor(brain, opts?)` is one reference occupant that summarises
283
+ with a model you provide. The summary is written to the session log as a
284
+ `compaction` event, so it is auditable and re-derivable rather than a silent
285
+ trim, and a boundary never severs a tool call from its result
286
+ (`CompactionRangeError` names the attempt). **Without a `compaction` port
287
+ nothing changes**; without a `sessionLog` the seat is inert, declared.
288
+ - New exports: `createBrainCompactor`; types `BrainCompactorOptions`,
289
+ `CompactionPort`, `CompactionDecision`, `ContextBudget`, `BrainChunk`,
290
+ `ChunkInfo`, `BrainToolCall`, `BrainOrchestration`.
291
+
292
+ ### Changed
293
+
294
+ - Requires `@msm-core/session` `^0.2.0` — the `compaction` event is the tenth
295
+ member of that package's closed event union.
296
+
297
+ ---
298
+
299
+ ## [0.7.0] — 2026-09-01
300
+
301
+ Sessions س٣ and س٤. Context starts being *derived*, and a step stops meaning one
302
+ tool.
303
+
304
+ ### Added
305
+
306
+ - **`AgentConfig.sessionLog?: SessionLogPort` — the loop writes an event log and
307
+ reads its context back out of it (س٣).** Injected, it inverts where
308
+ conversation context comes from: `deriveMessages(read())` instead of the
309
+ stored history array, with the same history budget applied. Events are written
310
+ **where they happen** — the user's message, each step, the raw pre-gate model
311
+ response, tool call/result pairs on every tool path (including refused and
312
+ unknown tools), guards that fired, the post-gate assistant message, and the
313
+ step's end on every exit including the `catch`. An invariant checks that every
314
+ field of `BrainRunInput` has exactly one counterpart in the log, and it runs
315
+ **before** the request goes to the model — a request that cannot be rebuilt
316
+ from the log should not be paid for. A rejected append fails the turn: the log
317
+ is the truth. **Absent, behavior is the previous version's bit for bit**;
318
+ `appendHistory` still runs, and the double write is transitional.
319
+ - **`BrainOrchestration.tool_calls?: BrainToolCall[]` — many tools in ONE model
320
+ round-trip (س٤).** Three tools now cost two round-trips instead of four.
321
+ **A pure addition**: a brain that fills only `tool_name` / `tool_params` is
322
+ normalised to a single-call step and takes exactly the path it always took,
323
+ and a brain that fills the array fills those two with `tool_calls[0]` as well.
324
+ The covenant is one-directional — new readers may read the array, old readers
325
+ never find a hole where the first call used to be. Approval fails closed **per
326
+ call**, `onBeforeTool` and dedup run per call, and a failing sibling does not
327
+ kill the others.
328
+ - New type `BrainToolCall`. New dependency: `@msm-core/session`.
329
+
330
+ ### Changed
331
+
332
+ - **Tool turns are role-tagged deterministically across all four providers**
333
+ (`[tool_call:<id>]` / `[tool_result:<id>]`) instead of being flattened into a
334
+ bare `user` message. ⚠️ This changes the prompt text your provider receives: a
335
+ model reading back its own tool history can now tell which side was which.
336
+
337
+ ---
338
+
339
+ ## [0.6.0] — 2026-09-01
340
+
341
+ Session س١. Session memory becomes a port.
342
+
343
+ ### Added
344
+
345
+ - **`AgentConfig.memory?: SessionStore` — inject your own session store.** The
346
+ loop used to build `new RedisMemory(...)` by hand in two places that had
347
+ already drifted apart from each other in silence. There is now one port (six
348
+ functions, taken at `RedisMemory`'s exact signatures) and one construction
349
+ site: an injected store wins, and absent one `RedisMemory` is built exactly as
350
+ before. `RedisMemory` implements `SessionStore`, so an existing configuration
351
+ needs no change.
352
+ - `InMemorySessionStore` in `@msm-core/mini/adapters` — a faithful RAM adapter
353
+ (tail limit, 500-entry cap, deep copy on both sides, so a returned reference
354
+ cannot be mutated into the store).
355
+ - New exported types: `SessionStore`; and `SessionMetadata`, moved to
356
+ `core/types` with a back-compatible re-export from its old home (zero measured
357
+ breaks).
358
+
359
+ ### Note — the covenant that arrives with the port
360
+
361
+ **Whoever injects a store injects its tenant isolation with it.** The loop
362
+ cannot enforce the `{prefix}:{companyId}:{agentType}` rule on a port it cannot
363
+ see into, so a store you supply owns its own tenant separation. The built-in
364
+ `RedisMemory` path is unchanged and still guarded.
365
+
366
+ `redis` remains required either way: the control bus, the distributed lock and
367
+ the dedup cache are still Redis-backed by direct construction, so "a loop with
368
+ no Redis at all" is not yet possible.
369
+
6
370
  ---
7
371
 
8
372
  ## [0.5.2] — 2026-07-01
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
@@ -10,7 +10,17 @@ export type { LockHandle } from "./redis-lock.js";
10
10
  export { InMemoryRedis, createInMemoryRedis } from "./memory-redis.js";
11
11
  /** In-RAM SessionStore — the memory port with no Redis and no network. */
12
12
  export { InMemorySessionStore } from "./memory-store.js";
13
+ /**
14
+ * The in-RAM trio (س٦) — with `InMemorySessionStore` above, these are the four
15
+ * ports a loop needs, so an agent can run start to finish with no Redis at all.
16
+ * `InMemoryLock` is a real mutex, not a stub; see its file.
17
+ */
18
+ export { InMemoryControlBus } from "./memory-control.js";
19
+ export { InMemoryLock } from "./memory-lock.js";
20
+ export { InMemoryToolDedup } from "./memory-dedup.js";
21
+ /** The Redis-backed dedup, behind the port — what the loop builds when `dedup` is omitted. */
22
+ export { RedisToolDedup } from "../tools/dedup.js";
13
23
  /** The client shape RedisConfig.client expects — for wiring a custom client. */
14
24
  export type { RedisLike } from "./redis-types.js";
15
- /** The port `AgentConfig.memory` accepts — for writing a custom session store. */
16
- export type { SessionStore } from "../core/types.js";
25
+ /** The ports `AgentConfig` accepts — for writing custom implementations. */
26
+ export type { ControlBusPort, DedupPort, RunLockPort, SessionStore, } from "../core/types.js";
@@ -8,3 +8,13 @@ export { RedisDistributedLock } from "./redis-lock.js";
8
8
  export { InMemoryRedis, createInMemoryRedis } from "./memory-redis.js";
9
9
  /** In-RAM SessionStore — the memory port with no Redis and no network. */
10
10
  export { InMemorySessionStore } from "./memory-store.js";
11
+ /**
12
+ * The in-RAM trio (س٦) — with `InMemorySessionStore` above, these are the four
13
+ * ports a loop needs, so an agent can run start to finish with no Redis at all.
14
+ * `InMemoryLock` is a real mutex, not a stub; see its file.
15
+ */
16
+ export { InMemoryControlBus } from "./memory-control.js";
17
+ export { InMemoryLock } from "./memory-lock.js";
18
+ export { InMemoryToolDedup } from "./memory-dedup.js";
19
+ /** The Redis-backed dedup, behind the port — what the loop builds when `dedup` is omitted. */
20
+ export { RedisToolDedup } from "../tools/dedup.js";
@@ -0,0 +1,43 @@
1
+ /**
2
+ * In-memory ControlBusPort — kill / pause / disable-tool with no Redis.
3
+ *
4
+ * The bundled counterpart to `RedisControlBus`, held in process memory. It is
5
+ * what makes a single-process deploy (a desktop app, an air-gapped install) or
6
+ * a test able to steer a running agent at all: before س٦ the only bus was a
7
+ * Redis key, so "kill this session" required infrastructure.
8
+ *
9
+ * Fidelity to `RedisControlBus` is the point, and it is fidelity to the
10
+ * *semantics*, not just to the method names — the loop reads a command string
11
+ * and acts on its verb, so a mirror that stored a different vocabulary would be
12
+ * a mirror of nothing:
13
+ * - `kill(id, reason)` stores `kill:{reason}`, default reason `"killed"` —
14
+ * the loop's guard reports the part after the colon.
15
+ * - `pause` stores `"pause"`; `disableTool` stores `disabled:{name}`.
16
+ * - `resume` clears ONLY a pause. A killed session stays killed — the Redis
17
+ * adapter reads before it deletes for exactly this reason, and a mirror
18
+ * that "resumed" a kill would quietly revive an aborted run.
19
+ * - one command per session: a later write replaces an earlier one.
20
+ *
21
+ * Scope: ONE process, and no expiry. The Redis bus gives a kill a 7-day TTL so
22
+ * the record outlives the worker that wrote it; nothing here outlives the
23
+ * process, so there is nothing to expire. For multi-replica deploys — where the
24
+ * whole point is that the killer and the killed are different processes — use
25
+ * `RedisControlBus`.
26
+ */
27
+ import type { ControlBusPort } from "../core/types.js";
28
+ export declare class InMemoryControlBus implements ControlBusPort {
29
+ private readonly commands;
30
+ getCommand(sessionId: string): Promise<string | null>;
31
+ kill(sessionId: string, reason?: string): Promise<void>;
32
+ pause(sessionId: string): Promise<void>;
33
+ /** Clears a pause and nothing else — a kill survives a resume. */
34
+ resume(sessionId: string): Promise<void>;
35
+ /**
36
+ * Refuse one tool for this session. Not part of `ControlBusPort` — the loop
37
+ * never writes it, it only reads the command — but present because the Redis
38
+ * bus has it and an operator on a no-Redis deploy needs the same lever.
39
+ */
40
+ disableTool(sessionId: string, toolName: string): Promise<void>;
41
+ /** Drop any command, whatever it is. Mirrors `RedisControlBus.clear`. */
42
+ clear(sessionId: string): Promise<void>;
43
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * In-memory ControlBusPort — kill / pause / disable-tool with no Redis.
3
+ *
4
+ * The bundled counterpart to `RedisControlBus`, held in process memory. It is
5
+ * what makes a single-process deploy (a desktop app, an air-gapped install) or
6
+ * a test able to steer a running agent at all: before س٦ the only bus was a
7
+ * Redis key, so "kill this session" required infrastructure.
8
+ *
9
+ * Fidelity to `RedisControlBus` is the point, and it is fidelity to the
10
+ * *semantics*, not just to the method names — the loop reads a command string
11
+ * and acts on its verb, so a mirror that stored a different vocabulary would be
12
+ * a mirror of nothing:
13
+ * - `kill(id, reason)` stores `kill:{reason}`, default reason `"killed"` —
14
+ * the loop's guard reports the part after the colon.
15
+ * - `pause` stores `"pause"`; `disableTool` stores `disabled:{name}`.
16
+ * - `resume` clears ONLY a pause. A killed session stays killed — the Redis
17
+ * adapter reads before it deletes for exactly this reason, and a mirror
18
+ * that "resumed" a kill would quietly revive an aborted run.
19
+ * - one command per session: a later write replaces an earlier one.
20
+ *
21
+ * Scope: ONE process, and no expiry. The Redis bus gives a kill a 7-day TTL so
22
+ * the record outlives the worker that wrote it; nothing here outlives the
23
+ * process, so there is nothing to expire. For multi-replica deploys — where the
24
+ * whole point is that the killer and the killed are different processes — use
25
+ * `RedisControlBus`.
26
+ */
27
+ export class InMemoryControlBus {
28
+ commands = new Map();
29
+ async getCommand(sessionId) {
30
+ return this.commands.get(sessionId) ?? null;
31
+ }
32
+ async kill(sessionId, reason = "killed") {
33
+ this.commands.set(sessionId, `kill:${reason}`);
34
+ }
35
+ async pause(sessionId) {
36
+ this.commands.set(sessionId, "pause");
37
+ }
38
+ /** Clears a pause and nothing else — a kill survives a resume. */
39
+ async resume(sessionId) {
40
+ if (this.commands.get(sessionId) === "pause") {
41
+ this.commands.delete(sessionId);
42
+ }
43
+ }
44
+ /**
45
+ * Refuse one tool for this session. Not part of `ControlBusPort` — the loop
46
+ * never writes it, it only reads the command — but present because the Redis
47
+ * bus has it and an operator on a no-Redis deploy needs the same lever.
48
+ */
49
+ async disableTool(sessionId, toolName) {
50
+ this.commands.set(sessionId, `disabled:${toolName}`);
51
+ }
52
+ /** Drop any command, whatever it is. Mirrors `RedisControlBus.clear`. */
53
+ async clear(sessionId) {
54
+ this.commands.delete(sessionId);
55
+ }
56
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * In-memory DedupPort — tool-call idempotency with no Redis.
3
+ *
4
+ * The bundled counterpart to the Redis-backed dedup (`RedisToolDedup`), held in
5
+ * process memory. It serves cached results **by the caller's own hash**, which
6
+ * is the only thing that makes it a mirror rather than a different feature: the
7
+ * hash is `hashToolCall(name, sortedArgs)` in both cases, so a call that Redis
8
+ * would have served from cache is served from cache here, and one it would have
9
+ * executed is executed here.
10
+ *
11
+ * Fidelity to the Redis semantics, in the two places they are not obvious:
12
+ *
13
+ * - **The TTL is per session, and it slides.** The Redis adapter keeps ONE
14
+ * hash per session and calls `EXPIRE` on that whole key at every store, so
15
+ * one busy tool keeps the entire session's cache alive and the whole thing
16
+ * drops together. This does the same: one `expiresAt` per session,
17
+ * refreshed on every store, and an expired session's cache reads as empty.
18
+ * A per-entry TTL would be the tidier design and would answer differently
19
+ * from production on exactly the runs that matter — long ones.
20
+ * - **Stored and returned values are deep-copied.** Redis stores JSON text,
21
+ * so a cached result handed back is always a fresh object. Holding the
22
+ * caller's reference instead would let a consumer that mutates a returned
23
+ * `ToolResult` rewrite the cache under itself, and the difference would only
24
+ * ever show up in production.
25
+ *
26
+ * Scope: ONE process. Two replicas each keep their own cache and will each run
27
+ * a call the other has already run — for shared dedup, use the Redis adapter.
28
+ */
29
+ import type { DedupPort, ToolResult } from "../core/types.js";
30
+ export declare class InMemoryToolDedup implements DedupPort {
31
+ private readonly sessions;
32
+ /** The live cache for a session, dropping it whole if its TTL has passed. */
33
+ private live;
34
+ check(sessionId: string, hash: string): Promise<ToolResult | null>;
35
+ store(sessionId: string, hash: string, result: ToolResult, ttlSeconds: number): Promise<void>;
36
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * In-memory DedupPort — tool-call idempotency with no Redis.
3
+ *
4
+ * The bundled counterpart to the Redis-backed dedup (`RedisToolDedup`), held in
5
+ * process memory. It serves cached results **by the caller's own hash**, which
6
+ * is the only thing that makes it a mirror rather than a different feature: the
7
+ * hash is `hashToolCall(name, sortedArgs)` in both cases, so a call that Redis
8
+ * would have served from cache is served from cache here, and one it would have
9
+ * executed is executed here.
10
+ *
11
+ * Fidelity to the Redis semantics, in the two places they are not obvious:
12
+ *
13
+ * - **The TTL is per session, and it slides.** The Redis adapter keeps ONE
14
+ * hash per session and calls `EXPIRE` on that whole key at every store, so
15
+ * one busy tool keeps the entire session's cache alive and the whole thing
16
+ * drops together. This does the same: one `expiresAt` per session,
17
+ * refreshed on every store, and an expired session's cache reads as empty.
18
+ * A per-entry TTL would be the tidier design and would answer differently
19
+ * from production on exactly the runs that matter — long ones.
20
+ * - **Stored and returned values are deep-copied.** Redis stores JSON text,
21
+ * so a cached result handed back is always a fresh object. Holding the
22
+ * caller's reference instead would let a consumer that mutates a returned
23
+ * `ToolResult` rewrite the cache under itself, and the difference would only
24
+ * ever show up in production.
25
+ *
26
+ * Scope: ONE process. Two replicas each keep their own cache and will each run
27
+ * a call the other has already run — for shared dedup, use the Redis adapter.
28
+ */
29
+ /** Deep copy through JSON — the same serialization boundary Redis imposes. */
30
+ function clone(value) {
31
+ return JSON.parse(JSON.stringify(value));
32
+ }
33
+ export class InMemoryToolDedup {
34
+ sessions = new Map();
35
+ /** The live cache for a session, dropping it whole if its TTL has passed. */
36
+ live(sessionId) {
37
+ const cache = this.sessions.get(sessionId);
38
+ if (!cache)
39
+ return undefined;
40
+ if (cache.expiresAt <= Date.now()) {
41
+ this.sessions.delete(sessionId);
42
+ return undefined;
43
+ }
44
+ return cache;
45
+ }
46
+ async check(sessionId, hash) {
47
+ const found = this.live(sessionId)?.entries.get(hash);
48
+ return found ? clone(found) : null;
49
+ }
50
+ async store(sessionId, hash, result, ttlSeconds) {
51
+ const cache = this.live(sessionId) ?? {
52
+ entries: new Map(),
53
+ expiresAt: 0,
54
+ };
55
+ cache.entries.set(hash, clone(result));
56
+ cache.expiresAt = Date.now() + ttlSeconds * 1000;
57
+ this.sessions.set(sessionId, cache);
58
+ }
59
+ }