@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.
@@ -142,7 +142,108 @@ export interface BrainRunInput {
142
142
  */
143
143
  export interface BrainChunk {
144
144
  text: string;
145
+ /**
146
+ * "Discard everything I have sent you; this text starts over." (ص٢/٢)
147
+ *
148
+ * Set on the FIRST chunk a retried attempt delivers, and on no other. ص١
149
+ * bought the display invariant — what reaches the reader is always a prefix
150
+ * of the payload — by muting the retry outright, which is honest but costs
151
+ * the tail of the answer: the display stops where the dead attempt stopped.
152
+ * This flag is what buys the same invariant WITHOUT that cost, because a
153
+ * reader who can clear what it drew has no prefix to contradict.
154
+ *
155
+ * **It never reaches a consumer that did not ask for it.** The sink has to
156
+ * declare itself with `acceptResets` (see `ResetAwareChunkSink`); everything
157
+ * else takes the ص١ path unchanged, byte for byte. That is deliberate: a
158
+ * consumer that appends chunks and ignores this field would render "The
159
+ * contract staThe contract states…" — the exact defect ب١/٢ opened — so the
160
+ * flag is delivered only where someone has said, in the type system, that
161
+ * they will act on it.
162
+ *
163
+ * `true` and absent are the only two states. There is no `reset: false`: a
164
+ * chunk either says "start over" or says nothing, and an optional literal
165
+ * makes the compiler enforce that rather than a code review.
166
+ */
167
+ reset?: true;
168
+ }
169
+ /**
170
+ * A chunk sink that has DECLARED it understands `BrainChunk.reset` (ص٢/٢).
171
+ *
172
+ * The declaration lives on the sink rather than in `BrainRunInput` or
173
+ * `RetryOpts`, and the reason is measured, not stylistic: the gate that decides
174
+ * to reset lives in `brain/retry.ts`, and the ONLY thing that crosses from a
175
+ * brain's input into that gate is the sink itself (`emitOnce(input.onChunk!)`).
176
+ * `RetryOpts` carries `signal` alone, and widening either it or `BrainRunInput`
177
+ * would mean editing all four brains to forward the new field. Attaching the
178
+ * declaration to the sink also makes it unmisplaceable: a consumer cannot
179
+ * declare reset support and then hand over a different callback.
180
+ *
181
+ * It also costs the replay harness nothing. `keyof BrainRunInput` does not
182
+ * change, so `InputKeysArePartitioned` in `@msm-core/replay` is untouched and
183
+ * no fingerprint moves — the ب١ precedent of a declared breach is not needed
184
+ * here, because there is no breach.
185
+ */
186
+ export interface ResetAwareChunkSink {
187
+ (chunk: BrainChunk): void;
188
+ /** The declaration itself. Read by `isResetAware`, never by a brain. */
189
+ readonly acceptsReset: true;
190
+ }
191
+ /**
192
+ * Declare that a chunk sink can handle `reset` — the opt-in, and the only way
193
+ * a `reset` chunk is ever produced.
194
+ *
195
+ * The contract you are signing: **when a chunk arrives with `reset: true`,
196
+ * throw away everything you have rendered for this call and start from that
197
+ * chunk.** Do that and the text you hold when the call returns is exactly the
198
+ * payload, with nothing missing and nothing shown twice.
199
+ *
200
+ * Returns a NEW function; the one you pass is not mutated.
201
+ */
202
+ export declare function acceptResets(sink: (chunk: BrainChunk) => void): ResetAwareChunkSink;
203
+ /** True for a sink that went through `acceptResets`. */
204
+ export declare function isResetAware(sink: unknown): sink is ResetAwareChunkSink;
205
+ /**
206
+ * An `AgentHooks.onChunk` that has DECLARED it understands `ChunkInfo.reset`
207
+ * (ص٣/٢) — the same opt-in as `ResetAwareChunkSink`, one layer out.
208
+ *
209
+ * ص٢ carried `reset` as far as a brain's chunk sink and stopped, because the
210
+ * loop's own sink drops everything but `text` into `fireChunk` and a declared
211
+ * loop sink would therefore have taken the flag and thrown it away — handing
212
+ * the hook consumer the duplicated display ب١/٢ exists to prevent. This type
213
+ * is the missing half: with it the loop can tell whether the person on the far
214
+ * end of `onChunk` will act on a reset, and it declares to the brain only when
215
+ * they will.
216
+ *
217
+ * **It is the same shape and the same marker as `ResetAwareChunkSink` on
218
+ * purpose.** A consumer who has learned the opt-in once has learned it in both
219
+ * places, and the loop's job stays a single question — did they declare? —
220
+ * rather than a translation between two conventions.
221
+ *
222
+ * Note that `AgentHooks.onChunk` needs no widening to accept this: a function
223
+ * carrying an extra property is already assignable to `(info: ChunkInfo) =>
224
+ * void`. The published signature of `AgentHooks` does not move.
225
+ */
226
+ export interface ResetAwareChunkHook {
227
+ (info: ChunkInfo): void;
228
+ /** The declaration itself. Read by `isResetAwareHook`, never by a brain. */
229
+ readonly acceptsReset: true;
145
230
  }
231
+ /**
232
+ * Declare that an `onChunk` hook can handle `ChunkInfo.reset` — the opt-in,
233
+ * and the only way a `reset` ever reaches a hook consumer.
234
+ *
235
+ * The contract you are signing: **when a `ChunkInfo` arrives with
236
+ * `reset: true`, throw away every chunk you have rendered for this step and
237
+ * start again from that one.** Do that and the text you hold at the end of the
238
+ * step is exactly the answer, with nothing missing and nothing shown twice.
239
+ * Ignore it and you would render the retried answer glued onto the truncated
240
+ * one — which is why the flag is delivered nowhere else.
241
+ *
242
+ * Returns a NEW function; the one you pass is not mutated.
243
+ */
244
+ export declare function acceptChunkResets(hook: (info: ChunkInfo) => void): ResetAwareChunkHook;
245
+ /** True for an `onChunk` hook that went through `acceptChunkResets`. */
246
+ export declare function isResetAwareHook(hook: unknown): hook is ResetAwareChunkHook;
146
247
  /**
147
248
  * One tool invocation a model asked for, in the model's own order.
148
249
  *
@@ -154,8 +255,45 @@ export interface BrainToolCall {
154
255
  name: string;
155
256
  params: Record<string, unknown>;
156
257
  }
258
+ /**
259
+ * Every action the loop DISPATCHES ON — declared as data, and the only list.
260
+ *
261
+ * ── Why this is a `const` array and not an inline union (ر٢) ────────────────
262
+ *
263
+ * There were two lists. This union, and a second copy in `bridge/pipeline.ts`
264
+ * that normalises whatever a duck-typed pipeline hands back. Both carried a
265
+ * fifth member the loop never dispatched on — a routing action that was
266
+ * declared at the beginning, implemented nowhere, and used by nobody (measured:
267
+ * zero occurrences in the live consumer). It survived precisely because
268
+ * deleting it meant finding and agreeing two places, and س١'s lesson is that
269
+ * two lists of the same thing drift apart silently and are only noticed by the
270
+ * damage.
271
+ *
272
+ * So there is now one list. The union below is derived from it and the bridge
273
+ * validates against it, which means a member cannot be added to one and
274
+ * forgotten in the other, and a member cannot be REMOVED from one and left
275
+ * standing in the other. `BrainActionsAreTheFourLiveOnes` then makes growing
276
+ * this array a decision someone has to write down rather than one that happens
277
+ * by omission.
278
+ *
279
+ * **What the loop actually does with each** (`core/loop.ts`):
280
+ * - `use_tool` → the step's tool calls run, then back to the model.
281
+ * - `clarify` / `escalate` → terminal, and the outcome carries that type.
282
+ * - `respond` → terminal, the text is delivered.
283
+ *
284
+ * And an action that is NONE of these — a rogue model emitting a string at
285
+ * runtime, where no type can stop it — takes the `respond` path: terminal,
286
+ * one iteration, whatever text the payload carried. That is the existing
287
+ * behaviour, it is fail-safe (a nonsense action never loops and never runs a
288
+ * tool), and it is pinned by a guard in `tests/delegate.test.ts` rather than
289
+ * left as an accident. The bridge's `respond` fallback for an unrecognised
290
+ * string is the same rule stated one layer earlier.
291
+ */
292
+ export declare const BRAIN_ACTIONS: readonly ["use_tool", "respond", "clarify", "escalate"];
293
+ /** What a model may ask the loop to do. Derived from `BRAIN_ACTIONS`. */
294
+ export type BrainAction = (typeof BRAIN_ACTIONS)[number];
157
295
  export interface BrainOrchestration {
158
- action: "use_tool" | "respond" | "clarify" | "escalate" | "delegate";
296
+ action: BrainAction;
159
297
  confidence: number;
160
298
  /**
161
299
  * The FIRST call of the step. Always filled whenever `tool_calls` is —
@@ -200,8 +338,56 @@ export interface BrainPayload {
200
338
  inputTokens?: number;
201
339
  outputTokens?: number;
202
340
  };
341
+ /**
342
+ * **Who actually answered** — the model as the PROVIDER's own reply named it.
343
+ *
344
+ * Not the model that was asked for. The two differ more often than they look
345
+ * like they should: a host swaps the brain under an agent definition at
346
+ * composition time (an `llmOverride`), a gateway routes a request elsewhere,
347
+ * or a provider resolves an alias to a dated snapshot. In every one of those
348
+ * cases the configured name is what the request *intended* and this is what
349
+ * the request *got*, and only the second can answer "which model wrote this?"
350
+ * about a reply already sent.
351
+ *
352
+ * The loop copies it into `model_response.data.model` in the session log,
353
+ * beside `model_request.data.model`, which keeps its own meaning — the
354
+ * requested/configured name. Two fields, two questions, neither guessing.
355
+ *
356
+ * **Optional, and absent means absent.** A brain that cannot know (a scripted
357
+ * one, or a provider whose reply does not name a model) leaves it off, and
358
+ * the log carries no `model` on that response rather than a plausible
359
+ * fabrication. Anything derived from the configured name would be exactly the
360
+ * falsehood this field exists to stop telling.
361
+ */
362
+ model?: string;
203
363
  [key: string]: unknown;
204
364
  }
365
+ /**
366
+ * The responder's name lifted off a raw provider reply — `{}` when it named none.
367
+ *
368
+ * Spread into a `BrainPayload` by all four bundled brains, so the reading exists
369
+ * once. Four copies of the same check in four files is four chances to drift,
370
+ * and the one difference between them (Gemini spells the field `modelVersion`;
371
+ * OpenAI, Anthropic and Ollama spell it `model`) is a fact about wire formats
372
+ * that belongs written down, not re-derived per file.
373
+ *
374
+ * **Why the parameter is `unknown`, and why there is no cast anyway.** The
375
+ * declared shape of each provider response in `brain/streaming.ts` is exactly
376
+ * the set of fields its brain reads — by design, stated there — and that set
377
+ * does not include the model name. A parameter typed `{ model?: unknown }`
378
+ * would therefore be rejected for every real argument: TypeScript's weak-type
379
+ * check refuses an object that shares no property with an all-optional target,
380
+ * which is precisely the situation here. So this takes what it is really given
381
+ * — an object off a wire — and narrows it at runtime with `typeof` and `in`.
382
+ * Nothing is asserted about it that has not just been tested.
383
+ *
384
+ * A reply that carries no name, or a non-string one, yields `{}`: the payload
385
+ * gets no `model` key, and the log records the absence honestly instead of
386
+ * inventing a name from the request.
387
+ */
388
+ export declare function respondingModel(reply: unknown): {
389
+ model?: string;
390
+ };
205
391
  /** The Brain interface — any model that implements this works with msm-mini */
206
392
  export interface Brain {
207
393
  name: string;
@@ -328,7 +514,9 @@ export interface SessionMetadata {
328
514
  *
329
515
  * The loop talks to this interface and never to a concrete store. Inject an
330
516
  * implementation via `AgentConfig.memory`; when it is omitted the loop builds
331
- * the bundled `RedisMemory` from `AgentConfig.redis` exactly as it always has.
517
+ * the bundled `RedisMemory` from `AgentConfig.redis` exactly as it always has
518
+ * and when `redis` is omitted too, `createAgent` refuses the composition by
519
+ * name rather than failing on the first history read (ص٤).
332
520
  *
333
521
  * Six functions — the surface `RedisMemory` already had, at its current
334
522
  * signatures. Nothing is added speculatively: an append-only event log is a
@@ -344,6 +532,103 @@ export interface SessionStore {
344
532
  getDocumentState(sessionId: string): Promise<DocumentState | null>;
345
533
  setDocumentState(sessionId: string, state: DocumentState): Promise<void>;
346
534
  }
535
+ /**
536
+ * The control bus PORT — out-of-band commands for a running session.
537
+ *
538
+ * One string per session, read once at the top of every loop iteration, before
539
+ * the model is called. The **command vocabulary is part of this contract**,
540
+ * because the loop interprets it (`checkGuards`, `RedisControlBus.disabledTool`):
541
+ *
542
+ * - `null` — no command; the run proceeds.
543
+ * - `"kill"` / `"kill:{reason}"` — hard abort, `terminatedBy: "task_killed"`.
544
+ * - `"pause"` — hard abort, `terminatedBy: "tenant_paused"`.
545
+ * - `"disabled:{tool}"` — that one tool is refused for the step; its
546
+ * siblings run.
547
+ *
548
+ * A verb is matched on the part before the first `:`, so an unknown verb is
549
+ * simply not a command anyone acts on — an implementation may carry its own
550
+ * without breaking the loop.
551
+ */
552
+ export interface ControlBusPort {
553
+ /** The current command for this session, or `null` when there is none. */
554
+ getCommand(sessionId: string): Promise<string | null>;
555
+ /** Write a kill command. `reason` rides along in the guard's message. */
556
+ kill(sessionId: string, reason?: string): Promise<void>;
557
+ pause(sessionId: string): Promise<void>;
558
+ /** Clear a pause. A `kill` is deliberately NOT cleared by this. */
559
+ resume(sessionId: string): Promise<void>;
560
+ }
561
+ /**
562
+ * A held session lock. `release()` is called on every exit path of the loop —
563
+ * normal, guard-terminated and error — and `extend()` is for a holder that
564
+ * outlives its own TTL.
565
+ *
566
+ * Declared here rather than in the Redis adapter for the same reason
567
+ * `SessionMetadata` moved in س١: it is part of the contract every lock
568
+ * implements, not a detail of one of them. `adapters/redis-lock.ts` re-exports
569
+ * it, so every existing import keeps working unchanged.
570
+ */
571
+ export interface LockHandle {
572
+ release(): Promise<void>;
573
+ extend(ttlMs: number): Promise<boolean>;
574
+ }
575
+ /**
576
+ * The session run-lock PORT — one turn per session at a time.
577
+ *
578
+ * **Named `RunLockPort`, not `LockPort`, deliberately.** `@msm-core/jobs` has
579
+ * exported a `LockPort` since long before this existed and it is a different
580
+ * contract entirely (`acquire(key, ttlMs): Promise<boolean>` — no handle, no
581
+ * release); nisus imports it today. Two packages of one SDK exporting one name
582
+ * for two contracts is a collision a consumer pays for in aliases forever, and
583
+ * it costs nothing to avoid before the first publish. The name is also the
584
+ * truer one: what this guards is a session's RUN, one turn at a time.
585
+ *
586
+ * The loop acquires before it does anything with the session and releases on
587
+ * every way out. This is the port whose in-RAM implementation has to be a real
588
+ * mutex and not a stub: a lock that always says yes turns "two workers cannot
589
+ * enter the same session" into a comment, and the failure it prevents (two
590
+ * turns interleaving their writes into one log) is silent when it happens.
591
+ *
592
+ * The loop calls only `acquireWithRetry` — waiting for a busy session is the
593
+ * behaviour it wants, so that is the surface extracted. Implementations are
594
+ * free to expose a non-blocking `acquire` beside it; the port does not require
595
+ * one because nothing in the loop asks for it.
596
+ */
597
+ export interface RunLockPort {
598
+ /**
599
+ * Acquire, retrying until `waitMs` elapses. Throws when it cannot.
600
+ *
601
+ * @param ttlMs lifetime of the lock — the loop passes the effective
602
+ * turn timeout plus a safety margin, so a lock never
603
+ * expires under a run that is still going.
604
+ * @param waitMs how long to keep trying (adapter default: 5000).
605
+ * @param retryInterval base delay between attempts (adapter default: 100).
606
+ */
607
+ acquireWithRetry(sessionId: string, ttlMs: number, waitMs?: number, retryInterval?: number): Promise<LockHandle>;
608
+ }
609
+ /**
610
+ * The tool-dedup PORT — idempotency for repeated tool calls within a session.
611
+ *
612
+ * Keyed by `(sessionId, hash)`, where the hash is `hashToolCall(name, args)`:
613
+ * a deterministic SHA-256 of the tool name and its key-sorted arguments. The
614
+ * hashing stays a pure function in `tools/dedup.ts` — it is not I/O and there
615
+ * is nothing to swap about it — so an implementation of this port is only ever
616
+ * asked to store and fetch by a key it is handed.
617
+ *
618
+ * **Only `ok` results are ever stored** (the executor's rule, not the port's):
619
+ * a failure is worth retrying, and caching one would freeze a transient outage
620
+ * into the session for the whole TTL.
621
+ */
622
+ export interface DedupPort {
623
+ /** A previously stored result for this exact call, or `null`. */
624
+ check(sessionId: string, hash: string): Promise<ToolResult | null>;
625
+ /**
626
+ * Remember a result for `ttlSeconds`. An implementation with no notion of
627
+ * expiry may ignore the TTL — it is a hint about staleness, and the loop
628
+ * passes `redis.ttl.toolDedup ?? 300` for it.
629
+ */
630
+ store(sessionId: string, hash: string, result: ToolResult, ttlSeconds: number): Promise<void>;
631
+ }
347
632
  /**
348
633
  * What a compactor decides when it decides to compact.
349
634
  *
@@ -443,7 +728,34 @@ export interface AgentConfig {
443
728
  /** Path to .md definition file or pre-parsed AgentDefinition object */
444
729
  definition: string | AgentDefinition;
445
730
  brain: Brain;
446
- redis: RedisConfig;
731
+ /**
732
+ * Redis connection for the four ports Redis can back: session `memory`, the
733
+ * `controlBus`, the run `lock` and tool `dedup`.
734
+ *
735
+ * **Optional (ص٤) — and only because س٦ earned it.** While the trio was
736
+ * `new`-ed inside the loop, `redis` was load-bearing for every run and being
737
+ * required was simply true. س٦ made all four injectable and proved a whole
738
+ * turn runs without opening a connection; the field stayed required after
739
+ * that for no reason but the calendar, so a fully-injected caller had to hand
740
+ * over an object it never used. The rule that replaces the requirement:
741
+ *
742
+ * - **Present** — nothing changes, for anyone. Every port that is not
743
+ * injected is built from this exactly as it always was: same
744
+ * `{prefix}:{companyId}:{agentType}` scoping, same key shapes, same TTLs,
745
+ * same moment of connection. `{}` is *present*, not absent: it has
746
+ * neither `url` nor `client`, so the first port that reaches for it
747
+ * throws as it always has — which is precisely what makes it useful as a
748
+ * test config that fails loudly if anything opens Redis.
749
+ * - **Absent** — every one of the four ports MUST be injected, because
750
+ * there is nothing left to fall back to. A composition that omits one
751
+ * is rejected by `createAgent` with an error naming the missing field;
752
+ * see the check at the top of `createAgent`.
753
+ *
754
+ * Absence is read from the value (`undefined`), not from the key, so a host
755
+ * that spreads an options object with `redis: undefined` in it gets the same
756
+ * named composition error rather than a crash inside a turn.
757
+ */
758
+ redis?: RedisConfig;
447
759
  /**
448
760
  * Optional session-memory port. When injected, the loop routes ALL history /
449
761
  * metadata / document-state access through it and never constructs a
@@ -451,10 +763,61 @@ export interface AgentConfig {
451
763
  * `redis` exactly as before — same prefix, same TTLs, same tenant scoping —
452
764
  * so every existing consumer is unaffected.
453
765
  *
454
- * `redis` stays required either way: the control bus, the session lock and
455
- * tool dedup are separate ports and still ride on it.
766
+ * Isolation is the injector's (س١ ruling 3): this port takes a `sessionId`
767
+ * and knows nothing of tenants. Whoever injects a store injects its tenant
768
+ * scoping with it.
769
+ *
770
+ * Omitting this **and** `redis` is a composition error, raised at
771
+ * `createAgent` (ص٤) — not a run that fails on its first history read.
456
772
  */
457
773
  memory?: SessionStore;
774
+ /**
775
+ * Optional **control-bus port** — kill / pause / disable-tool commands.
776
+ *
777
+ * Injected, the loop reads its commands and `agent.kill/pause/resume()` write
778
+ * through it. Omitted, the loop builds `RedisControlBus` from `redis` exactly
779
+ * as before: same `{prefix}:{companyId}:{agentType}` tenant scoping, same
780
+ * `:agent:control:{sessionId}` key, same 7-day TTL on a kill.
781
+ *
782
+ * Isolation is the injector's, as with `memory`. Note in particular that
783
+ * `agent.kill(sessionId, companyId, agentType)` computes a tenant-scoped
784
+ * prefix for the FALLBACK bus only — an injected bus receives the session id
785
+ * and nothing else, and must scope its own keys.
786
+ *
787
+ * Omitting this **and** `redis` is a composition error, raised at
788
+ * `createAgent` (ص٤).
789
+ */
790
+ controlBus?: ControlBusPort;
791
+ /**
792
+ * Optional **session-lock port** — one turn per session at a time.
793
+ *
794
+ * Omitted, the loop builds `RedisDistributedLock` from `redis` exactly as
795
+ * before (`SET NX PX` on `{prefix}:session:{id}:lock`, released by token).
796
+ *
797
+ * An injected lock must actually exclude, or it is not a lock. In-process
798
+ * (`InMemoryLock`) that means one process; across replicas it means a shared
799
+ * one. Isolation is the injector's here too: two tenants that share one
800
+ * injected lock share its key space, and a session id colliding across them
801
+ * will serialise turns that have nothing to do with each other.
802
+ *
803
+ * Omitting this **and** `redis` is a composition error, raised at
804
+ * `createAgent` (ص٤).
805
+ */
806
+ lock?: RunLockPort;
807
+ /**
808
+ * Optional **tool-dedup port** — idempotency for repeated tool calls.
809
+ *
810
+ * Omitted, the loop builds the Redis-backed dedup from `redis` exactly as
811
+ * before: `{prefix}:session:{id}:tools:dedup` as a hash, the call hash as the
812
+ * field, `EXPIRE` refreshed on every store.
813
+ *
814
+ * Isolation is the injector's: a dedup shared unscoped between tenants can
815
+ * serve one tenant's cached tool result to another's identical call.
816
+ *
817
+ * Omitting this **and** `redis` is a composition error, raised at
818
+ * `createAgent` (ص٤).
819
+ */
820
+ dedup?: DedupPort;
458
821
  /**
459
822
  * Optional session **event log** (`@msm-core/session`). Injecting it inverts
460
823
  * where conversation context comes from:
@@ -553,6 +916,25 @@ export interface ChunkInfo {
553
916
  /** The delta — not the running total. Concatenating every chunk of a step
554
917
  * yields the text that step's payload carries. */
555
918
  text: string;
919
+ /**
920
+ * "Discard everything I have sent you for this step; it starts over." (ص٣/٢)
921
+ *
922
+ * The hook-side face of `BrainChunk.reset`. Set on the FIRST chunk a retried
923
+ * attempt delivers, and on no other, so a consumer that clears and restarts
924
+ * on it ends the step holding exactly the answer — no truncation from the
925
+ * attempt that died, no duplication from the one that succeeded.
926
+ *
927
+ * **It never reaches a consumer that did not ask for it.** The hook must go
928
+ * through `acceptChunkResets` (see `ResetAwareChunkHook`); for every other
929
+ * hook the loop hands the brain an undeclared sink, `brain/retry.ts` takes
930
+ * the ص١ mute path, and the object delivered here carries the same three
931
+ * keys it carried before this field existed — the key is not merely
932
+ * `undefined`, it is absent. That is deliberate: a consumer that appends
933
+ * chunks and ignores this field would render the answer twice over.
934
+ *
935
+ * `true` and absent are the only two states, as on `BrainChunk`.
936
+ */
937
+ reset?: true;
556
938
  }
557
939
  /**
558
940
  * Return value from onBeforeTool hook:
@@ -4,4 +4,133 @@
4
4
  * All contracts for the lite agent runtime. Brain-agnostic, zero embedded
5
5
  * databases. Application layer owns persistence; agent owns the loop.
6
6
  */
7
- export {};
7
+ /**
8
+ * Declare that a chunk sink can handle `reset` — the opt-in, and the only way
9
+ * a `reset` chunk is ever produced.
10
+ *
11
+ * The contract you are signing: **when a chunk arrives with `reset: true`,
12
+ * throw away everything you have rendered for this call and start from that
13
+ * chunk.** Do that and the text you hold when the call returns is exactly the
14
+ * payload, with nothing missing and nothing shown twice.
15
+ *
16
+ * Returns a NEW function; the one you pass is not mutated.
17
+ */
18
+ export function acceptResets(sink) {
19
+ const declared = (chunk) => {
20
+ sink(chunk);
21
+ };
22
+ return Object.assign(declared, { acceptsReset: true });
23
+ }
24
+ /**
25
+ * The declaration marker, read in ONE place (ص٣/٢).
26
+ *
27
+ * There are two public predicates over this marker — one for a brain's chunk
28
+ * sink, one for the `AgentHooks.onChunk` a person writes — and س١'s rule is
29
+ * that two lookalikes drift apart in silence. They share this body so there is
30
+ * nothing to drift: the marker `acceptResets` and `acceptChunkResets` stamp is
31
+ * the same marker, and it is recognised the same way or not at all.
32
+ */
33
+ function declaresResets(value) {
34
+ return (typeof value === "function" &&
35
+ value.acceptsReset === true);
36
+ }
37
+ /** True for a sink that went through `acceptResets`. */
38
+ export function isResetAware(sink) {
39
+ return declaresResets(sink);
40
+ }
41
+ /**
42
+ * Declare that an `onChunk` hook can handle `ChunkInfo.reset` — the opt-in,
43
+ * and the only way a `reset` ever reaches a hook consumer.
44
+ *
45
+ * The contract you are signing: **when a `ChunkInfo` arrives with
46
+ * `reset: true`, throw away every chunk you have rendered for this step and
47
+ * start again from that one.** Do that and the text you hold at the end of the
48
+ * step is exactly the answer, with nothing missing and nothing shown twice.
49
+ * Ignore it and you would render the retried answer glued onto the truncated
50
+ * one — which is why the flag is delivered nowhere else.
51
+ *
52
+ * Returns a NEW function; the one you pass is not mutated.
53
+ */
54
+ export function acceptChunkResets(hook) {
55
+ const declared = (info) => {
56
+ hook(info);
57
+ };
58
+ return Object.assign(declared, { acceptsReset: true });
59
+ }
60
+ /** True for an `onChunk` hook that went through `acceptChunkResets`. */
61
+ export function isResetAwareHook(hook) {
62
+ return declaresResets(hook);
63
+ }
64
+ /**
65
+ * Every action the loop DISPATCHES ON — declared as data, and the only list.
66
+ *
67
+ * ── Why this is a `const` array and not an inline union (ر٢) ────────────────
68
+ *
69
+ * There were two lists. This union, and a second copy in `bridge/pipeline.ts`
70
+ * that normalises whatever a duck-typed pipeline hands back. Both carried a
71
+ * fifth member the loop never dispatched on — a routing action that was
72
+ * declared at the beginning, implemented nowhere, and used by nobody (measured:
73
+ * zero occurrences in the live consumer). It survived precisely because
74
+ * deleting it meant finding and agreeing two places, and س١'s lesson is that
75
+ * two lists of the same thing drift apart silently and are only noticed by the
76
+ * damage.
77
+ *
78
+ * So there is now one list. The union below is derived from it and the bridge
79
+ * validates against it, which means a member cannot be added to one and
80
+ * forgotten in the other, and a member cannot be REMOVED from one and left
81
+ * standing in the other. `BrainActionsAreTheFourLiveOnes` then makes growing
82
+ * this array a decision someone has to write down rather than one that happens
83
+ * by omission.
84
+ *
85
+ * **What the loop actually does with each** (`core/loop.ts`):
86
+ * - `use_tool` → the step's tool calls run, then back to the model.
87
+ * - `clarify` / `escalate` → terminal, and the outcome carries that type.
88
+ * - `respond` → terminal, the text is delivered.
89
+ *
90
+ * And an action that is NONE of these — a rogue model emitting a string at
91
+ * runtime, where no type can stop it — takes the `respond` path: terminal,
92
+ * one iteration, whatever text the payload carried. That is the existing
93
+ * behaviour, it is fail-safe (a nonsense action never loops and never runs a
94
+ * tool), and it is pinned by a guard in `tests/delegate.test.ts` rather than
95
+ * left as an accident. The bridge's `respond` fallback for an unrecognised
96
+ * string is the same rule stated one layer earlier.
97
+ */
98
+ export const BRAIN_ACTIONS = [
99
+ "use_tool",
100
+ "respond",
101
+ "clarify",
102
+ "escalate",
103
+ ];
104
+ /**
105
+ * The responder's name lifted off a raw provider reply — `{}` when it named none.
106
+ *
107
+ * Spread into a `BrainPayload` by all four bundled brains, so the reading exists
108
+ * once. Four copies of the same check in four files is four chances to drift,
109
+ * and the one difference between them (Gemini spells the field `modelVersion`;
110
+ * OpenAI, Anthropic and Ollama spell it `model`) is a fact about wire formats
111
+ * that belongs written down, not re-derived per file.
112
+ *
113
+ * **Why the parameter is `unknown`, and why there is no cast anyway.** The
114
+ * declared shape of each provider response in `brain/streaming.ts` is exactly
115
+ * the set of fields its brain reads — by design, stated there — and that set
116
+ * does not include the model name. A parameter typed `{ model?: unknown }`
117
+ * would therefore be rejected for every real argument: TypeScript's weak-type
118
+ * check refuses an object that shares no property with an all-optional target,
119
+ * which is precisely the situation here. So this takes what it is really given
120
+ * — an object off a wire — and narrows it at runtime with `typeof` and `in`.
121
+ * Nothing is asserted about it that has not just been tested.
122
+ *
123
+ * A reply that carries no name, or a non-string one, yields `{}`: the payload
124
+ * gets no `model` key, and the log records the absence honestly instead of
125
+ * inventing a name from the request.
126
+ */
127
+ export function respondingModel(reply) {
128
+ if (typeof reply !== "object" || reply === null)
129
+ return {};
130
+ const named = "model" in reply
131
+ ? reply.model
132
+ : "modelVersion" in reply
133
+ ? reply.modelVersion
134
+ : undefined;
135
+ return typeof named === "string" && named.length > 0 ? { model: named } : {};
136
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Skills — reusable instruction packs an agent loads, "files all the way down."
3
+ *
4
+ * Drop markdown into a folder and each file becomes part of the agent's
5
+ * definition, so the model gains that know-how with no rebuild and no code
6
+ * change. Two shapes are read, and they are the shapes Claude-style skill packs
7
+ * already use:
8
+ *
9
+ * skills/
10
+ * summarizing/SKILL.md ← a folder pack
11
+ * tone-of-voice.md ← a single-file skill
12
+ *
13
+ * `loadSkills(dir)` returns ONE markdown block (`## Skills`), or `""` when
14
+ * there is nothing to load. **Composition stays with the host**: this module
15
+ * reads files and concatenates them; appending the block to a definition is the
16
+ * caller's line of code. The loop is not involved and does not know skills
17
+ * exist — `parseDefinition` has always *stripped* a `## Skills` section as
18
+ * "owned by the app layer", and that stays true.
19
+ *
20
+ * ── Lifted from `nisus/runtime/skills/loader.ts`, hardened where it mattered ──
21
+ *
22
+ * The pattern was right, so it was carried over rather than reinvented. Three
23
+ * things changed, each for a failure mode, and each pinned by a guard in
24
+ * `tests/skills.test.ts` — including a differential test against a verbatim
25
+ * transcription of the original, so any divergence beyond these is a bug:
26
+ *
27
+ * 1. **The order is deterministic.** The original iterated `readdirSync`
28
+ * directly, and that order is filesystem- and platform-dependent (APFS
29
+ * hands back roughly hash order; ext4 differs again). Two machines with
30
+ * the same two skills therefore built two different definitions — the same
31
+ * agent with two fingerprints, which is the enemy of replay
32
+ * (`@msm-core/replay` fingerprints `system_context`) and of any diff a
33
+ * human tries to read. Entries are now sorted by code unit before they are
34
+ * read. **Not** `localeCompare`: that depends on ICU data and the ambient
35
+ * locale, which would reintroduce exactly the nondeterminism being
36
+ * removed.
37
+ *
38
+ * 2. **Diagnostics go to an injected port, never to `console`.** Same reason
39
+ * as `@msm-core/mcp`'s `McpLogPort`: a library that prints is a library you
40
+ * cannot embed. Absent a port, the loader is silent.
41
+ *
42
+ * 3. **An empty skill file contributes nothing instead of a bare separator.**
43
+ * The original pushed `readFileSync(...).trim()` unconditionally, so an
44
+ * empty (or whitespace-only) `.md` injected `"\n\n---\n\n\n\n---\n\n"`
45
+ * into the text the model reads — a horizontal rule with no section under
46
+ * it, mid-definition. It is now skipped, with a warning naming the file,
47
+ * because a file someone left empty is a mistake worth hearing about.
48
+ *
49
+ * What deliberately did NOT change: a folder without a `SKILL.md` is skipped in
50
+ * silence (it is a plain folder, not a failure); a non-`.md` file is ignored;
51
+ * an entry that throws is reported and its siblings still load; and a folder
52
+ * pack and a single-file skill sitting side by side are BOTH read — the folder
53
+ * from its `SKILL.md`, the file from itself. That last one is the precedence
54
+ * rule, and it is a rule rather than an accident because a guard says so.
55
+ */
56
+ /**
57
+ * Where the loader's diagnostics go — a port, not a console.
58
+ *
59
+ * Shaped so a plain `console`-like object, or the `SimpleLog` the original
60
+ * loader took, satisfies it without a wrapper.
61
+ */
62
+ export interface SkillsLogPort {
63
+ info?(message: string): void;
64
+ warn?(message: string): void;
65
+ }
66
+ /**
67
+ * Read every skill under `dir` and return them as one markdown block, or `""`
68
+ * when the directory is missing, is not a directory, or holds no skills.
69
+ *
70
+ * Never throws for a skill it cannot read: the failure is reported on `log` and
71
+ * the remaining skills still load. A definition missing one section is worth
72
+ * more than an agent that will not start.
73
+ */
74
+ export declare function loadSkills(dir: string, log?: SkillsLogPort): string;