@msm-core/mini 0.8.0 → 0.14.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,285 @@
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.14.0] — 2026-09-01
16
+
17
+ Session ص٤. The Redis-mandatory era ends.
18
+
19
+ ### Changed
20
+
21
+ - `AgentConfig.redis` is **optional**. Present: byte-for-byte today's
22
+ behavior. Absent with all four ports injected (memory, controlBus, lock,
23
+ dedup): the loop runs with no Redis at all. Absent with any port missing:
24
+ `createAgent` fails immediately with an error that names the missing
25
+ port — and only it — and points at the `/adapters` in-memory
26
+ implementations. Absence is read from the VALUE (`=== undefined`), so a
27
+ host spreading `redis: undefined` gets the named error, not a TypeError
28
+ from the internals.
29
+
30
+ ## [0.13.0] — 2026-09-01
31
+
32
+ Session س٦ (both rounds). The loop can now run with no Redis at all — and
33
+ the audit log names the model that actually answered.
34
+
35
+ ### Added
36
+
37
+ - Three injected ports on the س١ pattern: `ControlBusPort`, `RunLockPort`
38
+ (named to avoid `@msm-core/jobs`'s `LockPort` — the reason lives in the
39
+ port's own comment), `DedupPort` — `AgentConfig` accepts them optionally;
40
+ absent means the bundled Redis trio exactly as before. In-memory adapters
41
+ for all three; with all four ports injected the loop measurably issues
42
+ ZERO Redis calls.
43
+ - Model truth: the four brains fill `BrainPayload.model` from the
44
+ provider's own reply — streamed and non-streamed both — and the loop
45
+ writes it to `model_response.data.model` (see `@msm-core/session` 0.3.0
46
+ for the event contract). `respondingModel` exported.
47
+
48
+ ### Notes
49
+
50
+ - "Who injects a port injects its isolation" extends to the three new
51
+ ports, guarded behaviorally with two tenants.
52
+
53
+ ## [0.12.0] — 2026-09-01
54
+
55
+ Session ص٣, entry with the bump (the new ledger guard enforces what
56
+ Circular 2 asked by hand).
57
+
58
+ ### Added
59
+
60
+ - The reset channel reaches `AgentHooks.onChunk`: `ChunkInfo.reset?: true`,
61
+ `fireChunk` spreads it, and a hooks consumer opts in with
62
+ `acceptChunkResets(hook)` (`isResetAwareHook`, `ResetAwareChunkHook`
63
+ exported). ص٢'s covenant one layer up: an undeclared consumer's chunks
64
+ are key-for-key what they always were — reset never reaches anyone who
65
+ did not ask for it.
66
+ - Ledger guards in mini/session/replay/mcp: `package.json.version` must
67
+ equal the CHANGELOG head — no seventh unrecorded release.
68
+
69
+ ### Changed
70
+
71
+ - mcp's spawned-process tests comply with Council Circular 1 (stderr in
72
+ every failure text, named waits, declared readiness) — diagnostics only;
73
+ the 98 tests and their assertions are unchanged.
74
+
75
+ ## [0.11.0] — 2026-09-01
76
+
77
+ Session م١, entry by management at acceptance (Circular 2: the ledger rides
78
+ with the bump, not behind it).
79
+
80
+ ### Added
81
+
82
+ - `loadSkills(dir, log?)` in the definition layer — Claude-style skill packs
83
+ (`skills/<pack>/SKILL.md` folders and single `.md` files) read into one
84
+ `## Skills` markdown block for the host to append to an agent definition.
85
+ Lifted from nisus's `runtime/skills/loader.ts` and hardened: deterministic
86
+ code-unit ordering (platform-independent definitions — replay-stable
87
+ fingerprints), an injected `SkillsLogPort` instead of console, and empty
88
+ skill files contribute nothing rather than a bare separator (a declared,
89
+ parity-tested divergence from the original). Composition stays with the
90
+ host: the code moved, the wiring did not — zero loop changes.
91
+
92
+ ## [0.10.0] — 2026-09-01
93
+
94
+ Session ص٢. A retried stream can finally repair the display it interrupted.
95
+
96
+ ### Added
97
+
98
+ - **`BrainChunk.reset` — a retried call can tell you to clear what you drew.**
99
+ Since 0.9.1 a retried brain call stops re-emitting text you already saw, which
100
+ never truncates the payload but does leave the display frozen at the cut. A
101
+ consumer that *can* clear what it has drawn now opts in **on its sink**:
102
+ `acceptResets(sink)` marks it, `isResetAware(sink)` reports it, and
103
+ `ResetAwareChunkSink` is the type. A marked sink gets `reset: true` on the
104
+ first chunk after a retry and redraws the answer whole. **An unmarked sink is
105
+ on 0.9.1's path byte for byte** — the opt-in is the sink's, so a consumer that
106
+ does nothing sees nothing new.
107
+ - New exports: `acceptResets`, `isResetAware`, type `ResetAwareChunkSink`.
108
+
109
+ ### Changed
110
+
111
+ - Internal, no surface: every package's test tree is now type-checked by `tsc`
112
+ inside `npm test` (17 of 53 test files were under a compiler before; 54 of 54
113
+ now). This is why a type error can no longer sit green for months.
114
+
115
+ ---
116
+
117
+ ## [0.9.1] — 2026-09-01
118
+
119
+ Session ص١ — three internal debts, two of them visible from outside.
120
+
121
+ ### Fixed
122
+
123
+ - **A stamped tool's `destructive` / `category` now reach the model.**
124
+ `toToolDefinitions` declared both fields and dropped them on the way out, so a
125
+ tool marked destructive by the MCP adapter or by `createDelegateTool` was
126
+ described to the provider like any other tool. They are forwarded now.
127
+ - **A retried brain call no longer streams the same words twice.** `withRetry`
128
+ restarted the provider call and the new attempt re-emitted text the consumer
129
+ had already rendered. The gate is keyed on **what was delivered**, not on the
130
+ attempt number: what reaches your `onChunk` is always a prefix of the final
131
+ payload — never longer than it, never contradicting it — and the worst case is
132
+ a display that stops where the cut happened rather than one that repeats or
133
+ truncates mid-sentence. Display only; `LoopOutcome` was always correct.
134
+
135
+ ### Changed
136
+
137
+ - Internal: `mini`'s test tree is type-checked on every `npm test`. Nine
138
+ long-standing type errors in old tests were fixed **types only** — no test's
139
+ behavior and no test count changed.
140
+
141
+ ---
142
+
143
+ ## [0.9.0] — 2026-09-01
144
+
145
+ Session ر٢. Delegation stops being a dead union member and becomes a tool.
146
+
147
+ ### Removed — ⚠️ a type breaks; no runtime does
148
+
149
+ - **`"delegate"` is gone from the `BrainOrchestration.action` union.** It had
150
+ been declared since 0.1.0 and **the loop never executed it** — a brain
151
+ returning it fell through to the unknown-action path. Measured before the
152
+ removal: zero occurrences in `mini/src`, zero in nisus, and none of the four
153
+ brains ever emitted it. If you have a third-party brain that returns
154
+ `action: "delegate"`, it no longer type-checks, and at runtime it behaves
155
+ exactly as it always did: a terminal `respond` that delivers text and cannot
156
+ smuggle a tool call past the dispatcher (now pinned by a guard).
157
+
158
+ ### Added
159
+
160
+ - **`createDelegateTool(delegates, opts?)` — one agent asks another, as an
161
+ ordinary `Tool`.** It returns a plain `call_agent` tool over
162
+ `Record<string, Agent>`, with the delegate names as an `enum` parameter so the
163
+ model cannot invent one. The child's session id is **derived**, not minted:
164
+ `${sessionId}.d.${name}` — deterministic, readable in a log, identical across
165
+ runs. The parent's `tenantContext` is inherited by the child.
166
+ `opts.maxDepth` (default 1) **fails closed** with a named `failed` result
167
+ rather than an exception. The child's cost comes back inside the tool result,
168
+ visible and not swallowed — it is deliberately *not* folded into the parent's
169
+ total, because counting it twice is worse than not counting it.
170
+ `opts.requiresApproval` / `destructive` / `category` stamp the definition, so
171
+ fail-closed approval covers a delegation with no new code.
172
+ - New exports: `createDelegateTool`, `DELEGATE_TOOL_NAME`, types `DelegateTool`,
173
+ `DelegateToolOptions`.
174
+ - **Zero loop changes.** A delegation passes guards, approval, dedup and the
175
+ session log exactly like any other tool — which is the whole argument for a
176
+ tool over an action.
177
+
178
+ ---
179
+
180
+ ## [0.8.0] — 2026-09-01
181
+
182
+ Sessions ض١ and ب١. Two optional seats: one for compaction, one for streaming.
183
+
184
+ ### Added
185
+
186
+ - **Streaming — `BrainRunInput.onChunk` and `AgentHooks.onChunk` (ب١).** All
187
+ four brains stream natively, each behind one branch; `BrainChunk` is
188
+ `{ text }`, and `ChunkInfo` adds `{ sessionId, iteration }` at the hook. Token
189
+ usage still arrives on the streamed path (`stream_options.include_usage`), so
190
+ `costCapPerTask` keeps working while streaming. **Streaming is display, not
191
+ truth**: no stream event is written to the session log, and
192
+ `@msm-core/replay`'s fingerprint is blind to `onChunk` — a run someone watched
193
+ and a run nobody watched are the same run. Without a handler the path is
194
+ literally the previous one, a single `if` apart.
195
+ - **Compaction as an injected seat — `AgentConfig.compaction?: CompactionPort`
196
+ (ض١).** The port decides when a long conversation is summarised;
197
+ `createBrainCompactor(brain, opts?)` is one reference occupant that summarises
198
+ with a model you provide. The summary is written to the session log as a
199
+ `compaction` event, so it is auditable and re-derivable rather than a silent
200
+ trim, and a boundary never severs a tool call from its result
201
+ (`CompactionRangeError` names the attempt). **Without a `compaction` port
202
+ nothing changes**; without a `sessionLog` the seat is inert, declared.
203
+ - New exports: `createBrainCompactor`; types `BrainCompactorOptions`,
204
+ `CompactionPort`, `CompactionDecision`, `ContextBudget`, `BrainChunk`,
205
+ `ChunkInfo`, `BrainToolCall`, `BrainOrchestration`.
206
+
207
+ ### Changed
208
+
209
+ - Requires `@msm-core/session` `^0.2.0` — the `compaction` event is the tenth
210
+ member of that package's closed event union.
211
+
212
+ ---
213
+
214
+ ## [0.7.0] — 2026-09-01
215
+
216
+ Sessions س٣ and س٤. Context starts being *derived*, and a step stops meaning one
217
+ tool.
218
+
219
+ ### Added
220
+
221
+ - **`AgentConfig.sessionLog?: SessionLogPort` — the loop writes an event log and
222
+ reads its context back out of it (س٣).** Injected, it inverts where
223
+ conversation context comes from: `deriveMessages(read())` instead of the
224
+ stored history array, with the same history budget applied. Events are written
225
+ **where they happen** — the user's message, each step, the raw pre-gate model
226
+ response, tool call/result pairs on every tool path (including refused and
227
+ unknown tools), guards that fired, the post-gate assistant message, and the
228
+ step's end on every exit including the `catch`. An invariant checks that every
229
+ field of `BrainRunInput` has exactly one counterpart in the log, and it runs
230
+ **before** the request goes to the model — a request that cannot be rebuilt
231
+ from the log should not be paid for. A rejected append fails the turn: the log
232
+ is the truth. **Absent, behavior is the previous version's bit for bit**;
233
+ `appendHistory` still runs, and the double write is transitional.
234
+ - **`BrainOrchestration.tool_calls?: BrainToolCall[]` — many tools in ONE model
235
+ round-trip (س٤).** Three tools now cost two round-trips instead of four.
236
+ **A pure addition**: a brain that fills only `tool_name` / `tool_params` is
237
+ normalised to a single-call step and takes exactly the path it always took,
238
+ and a brain that fills the array fills those two with `tool_calls[0]` as well.
239
+ The covenant is one-directional — new readers may read the array, old readers
240
+ never find a hole where the first call used to be. Approval fails closed **per
241
+ call**, `onBeforeTool` and dedup run per call, and a failing sibling does not
242
+ kill the others.
243
+ - New type `BrainToolCall`. New dependency: `@msm-core/session`.
244
+
245
+ ### Changed
246
+
247
+ - **Tool turns are role-tagged deterministically across all four providers**
248
+ (`[tool_call:<id>]` / `[tool_result:<id>]`) instead of being flattened into a
249
+ bare `user` message. ⚠️ This changes the prompt text your provider receives: a
250
+ model reading back its own tool history can now tell which side was which.
251
+
252
+ ---
253
+
254
+ ## [0.6.0] — 2026-09-01
255
+
256
+ Session س١. Session memory becomes a port.
257
+
258
+ ### Added
259
+
260
+ - **`AgentConfig.memory?: SessionStore` — inject your own session store.** The
261
+ loop used to build `new RedisMemory(...)` by hand in two places that had
262
+ already drifted apart from each other in silence. There is now one port (six
263
+ functions, taken at `RedisMemory`'s exact signatures) and one construction
264
+ site: an injected store wins, and absent one `RedisMemory` is built exactly as
265
+ before. `RedisMemory` implements `SessionStore`, so an existing configuration
266
+ needs no change.
267
+ - `InMemorySessionStore` in `@msm-core/mini/adapters` — a faithful RAM adapter
268
+ (tail limit, 500-entry cap, deep copy on both sides, so a returned reference
269
+ cannot be mutated into the store).
270
+ - New exported types: `SessionStore`; and `SessionMetadata`, moved to
271
+ `core/types` with a back-compatible re-export from its old home (zero measured
272
+ breaks).
273
+
274
+ ### Note — the covenant that arrives with the port
275
+
276
+ **Whoever injects a store injects its tenant isolation with it.** The loop
277
+ cannot enforce the `{prefix}:{companyId}:{agentType}` rule on a port it cannot
278
+ see into, so a store you supply owns its own tenant separation. The built-in
279
+ `RedisMemory` path is unchanged and still guarded.
280
+
281
+ `redis` remains required either way: the control bus, the distributed lock and
282
+ the dedup cache are still Redis-backed by direct construction, so "a loop with
283
+ no Redis at all" is not yet possible.
284
+
6
285
  ---
7
286
 
8
287
  ## [0.5.2] — 2026-07-01
@@ -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
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * In-memory RunLockPort — a session mutex with no Redis.
3
+ *
4
+ * **This one has to actually lock.** A stub that always grants would make every
5
+ * test of the no-Redis loop pass while removing the guarantee the lock exists
6
+ * for: one turn per session at a time. Two turns running into one session
7
+ * interleave their history writes and their log appends, and the damage is
8
+ * silent — a log with two turns braided together still reads as a log. So the
9
+ * in-RAM lock is a real mutex, and it is guarded by a test that starts a second
10
+ * turn while the first is still inside the brain call and watches it wait.
11
+ *
12
+ * Fidelity to `RedisDistributedLock`, semantics first:
13
+ * - **held is held**: `acquire` returns `null` for a session already locked,
14
+ * rather than a second handle.
15
+ * - **TTL is honoured**: a lock past its `ttlMs` is treated as gone, exactly
16
+ * as `SET PX` lets Redis drop it. A RAM lock that never expired would
17
+ * deadlock a session that Redis would have freed — the mirror would be
18
+ * safer than the original, which is its own kind of lie.
19
+ * - **release and extend are token-checked**: only the holder's own handle
20
+ * can release or extend, which is what the `GET == token` Lua scripts buy
21
+ * on the Redis side. A late release from a previous holder whose lock has
22
+ * already expired must not unlock the current one.
23
+ * - **the retry loop is the same shape**: attempt, jittered sleep, deadline —
24
+ * and the same error message when the deadline passes, because that string
25
+ * is what surfaces from the loop when a session is genuinely stuck.
26
+ *
27
+ * Scope: ONE process. Across replicas this excludes nothing at all, and that is
28
+ * not a limitation to work around — a mutex for a single-process deploy is the
29
+ * whole of what it claims to be. Multi-replica deploys use
30
+ * `RedisDistributedLock`.
31
+ */
32
+ import type { LockHandle, RunLockPort } from "../core/types.js";
33
+ export declare class InMemoryLock implements RunLockPort {
34
+ private readonly held;
35
+ /** The live holder of this session, or `undefined` if free or expired. */
36
+ private current;
37
+ /**
38
+ * Take the lock, or return `null` if someone else holds it.
39
+ * Not required by `RunLockPort` — the loop only ever waits — but it is the
40
+ * primitive `acquireWithRetry` is built from, and `RedisDistributedLock`
41
+ * exposes it too.
42
+ */
43
+ acquire(sessionId: string, ttlMs: number): Promise<LockHandle | null>;
44
+ acquireWithRetry(sessionId: string, ttlMs: number, waitMs?: number, retryInterval?: number): Promise<LockHandle>;
45
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * In-memory RunLockPort — a session mutex with no Redis.
3
+ *
4
+ * **This one has to actually lock.** A stub that always grants would make every
5
+ * test of the no-Redis loop pass while removing the guarantee the lock exists
6
+ * for: one turn per session at a time. Two turns running into one session
7
+ * interleave their history writes and their log appends, and the damage is
8
+ * silent — a log with two turns braided together still reads as a log. So the
9
+ * in-RAM lock is a real mutex, and it is guarded by a test that starts a second
10
+ * turn while the first is still inside the brain call and watches it wait.
11
+ *
12
+ * Fidelity to `RedisDistributedLock`, semantics first:
13
+ * - **held is held**: `acquire` returns `null` for a session already locked,
14
+ * rather than a second handle.
15
+ * - **TTL is honoured**: a lock past its `ttlMs` is treated as gone, exactly
16
+ * as `SET PX` lets Redis drop it. A RAM lock that never expired would
17
+ * deadlock a session that Redis would have freed — the mirror would be
18
+ * safer than the original, which is its own kind of lie.
19
+ * - **release and extend are token-checked**: only the holder's own handle
20
+ * can release or extend, which is what the `GET == token` Lua scripts buy
21
+ * on the Redis side. A late release from a previous holder whose lock has
22
+ * already expired must not unlock the current one.
23
+ * - **the retry loop is the same shape**: attempt, jittered sleep, deadline —
24
+ * and the same error message when the deadline passes, because that string
25
+ * is what surfaces from the loop when a session is genuinely stuck.
26
+ *
27
+ * Scope: ONE process. Across replicas this excludes nothing at all, and that is
28
+ * not a limitation to work around — a mutex for a single-process deploy is the
29
+ * whole of what it claims to be. Multi-replica deploys use
30
+ * `RedisDistributedLock`.
31
+ */
32
+ import { randomBytes } from "crypto";
33
+ export class InMemoryLock {
34
+ held = new Map();
35
+ /** The live holder of this session, or `undefined` if free or expired. */
36
+ current(sessionId) {
37
+ const entry = this.held.get(sessionId);
38
+ if (!entry)
39
+ return undefined;
40
+ if (entry.expiresAt <= Date.now()) {
41
+ this.held.delete(sessionId);
42
+ return undefined;
43
+ }
44
+ return entry;
45
+ }
46
+ /**
47
+ * Take the lock, or return `null` if someone else holds it.
48
+ * Not required by `RunLockPort` — the loop only ever waits — but it is the
49
+ * primitive `acquireWithRetry` is built from, and `RedisDistributedLock`
50
+ * exposes it too.
51
+ */
52
+ async acquire(sessionId, ttlMs) {
53
+ if (this.current(sessionId))
54
+ return null;
55
+ const token = randomBytes(16).toString("hex");
56
+ this.held.set(sessionId, { token, expiresAt: Date.now() + ttlMs });
57
+ const ownsIt = () => this.current(sessionId)?.token === token;
58
+ return {
59
+ release: async () => {
60
+ // Token-checked: a handle whose lock already expired and was retaken
61
+ // must not release the new holder's lock.
62
+ if (ownsIt())
63
+ this.held.delete(sessionId);
64
+ },
65
+ extend: async (newTtlMs) => {
66
+ if (!ownsIt())
67
+ return false;
68
+ this.held.set(sessionId, {
69
+ token,
70
+ expiresAt: Date.now() + newTtlMs,
71
+ });
72
+ return true;
73
+ },
74
+ };
75
+ }
76
+ async acquireWithRetry(sessionId, ttlMs, waitMs = 5000, retryInterval = 100) {
77
+ const deadline = Date.now() + waitMs;
78
+ while (Date.now() < deadline) {
79
+ const handle = await this.acquire(sessionId, ttlMs);
80
+ if (handle)
81
+ return handle;
82
+ await sleep(retryInterval + Math.random() * 50);
83
+ }
84
+ throw new Error(`msm-mini: could not acquire lock for session ${sessionId} within ${waitMs}ms`);
85
+ }
86
+ }
87
+ function sleep(ms) {
88
+ return new Promise((resolve) => setTimeout(resolve, ms));
89
+ }
@@ -7,7 +7,8 @@
7
7
  * Checked on every loop iteration before the brain call.
8
8
  */
9
9
  import type { RedisLike } from "./redis-types.js";
10
- export declare class RedisControlBus {
10
+ import type { ControlBusPort } from "../core/types.js";
11
+ export declare class RedisControlBus implements ControlBusPort {
11
12
  private readonly redis;
12
13
  private readonly prefix;
13
14
  constructor(redis: RedisLike, prefix?: string);