@msm-core/mini 0.5.1 → 0.8.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.
@@ -4,6 +4,8 @@
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
+ import type { SessionLogPort } from "@msm-core/session";
8
+ import type { ContextBudget } from "./context-builder.js";
7
9
  /**
8
10
  * A single memory entry — assembled by msm-context or by the app directly.
9
11
  * The agent injects these into every brain call in priority order.
@@ -114,12 +116,67 @@ export interface BrainRunInput {
114
116
  * safe — the loop still stops waiting via its timeout race.
115
117
  */
116
118
  signal?: AbortSignal;
119
+ /**
120
+ * Present ONLY when a consumer asked to see the answer as it is produced
121
+ * (ب١). Its absence is the normal case and means "do not stream" — every
122
+ * built-in brain then walks the exact non-streaming path it always walked.
123
+ *
124
+ * **Display, not truth.** What the loop records, gates, returns and replays
125
+ * is still the completed `BrainPayload`. A chunk is never logged as an event,
126
+ * never enters a `replay` fingerprint, and never changes the session-log
127
+ * invariant. Drop every chunk on the floor and the run is bit-identical.
128
+ *
129
+ * A brain that ignores this field is CORRECT, not broken — it simply does not
130
+ * stream. Third-party brains written before ب١ keep working untouched.
131
+ */
132
+ onChunk?: (chunk: BrainChunk) => void;
133
+ }
134
+ /**
135
+ * One piece of a model answer as it arrives.
136
+ *
137
+ * **Text only in v1.** Streaming tool CALLS is deliberately out of scope: the
138
+ * brain still accumulates them and returns them in the payload exactly as it
139
+ * does today, so a half-arrived tool call is never observable and never
140
+ * executable. The type is an object rather than a bare `string` so that day —
141
+ * if it comes — adds a field instead of breaking every consumer's signature.
142
+ */
143
+ export interface BrainChunk {
144
+ text: string;
145
+ }
146
+ /**
147
+ * One tool invocation a model asked for, in the model's own order.
148
+ *
149
+ * The unit is the CALL, not the step: modern models emit several in one
150
+ * response, and a step-shaped type would force the loop back into one
151
+ * round-trip per tool — the cost this exists to remove.
152
+ */
153
+ export interface BrainToolCall {
154
+ name: string;
155
+ params: Record<string, unknown>;
117
156
  }
118
157
  export interface BrainOrchestration {
119
158
  action: "use_tool" | "respond" | "clarify" | "escalate" | "delegate";
120
159
  confidence: number;
160
+ /**
161
+ * The FIRST call of the step. Always filled whenever `tool_calls` is —
162
+ * never left behind as `tool_calls` grows. Every consumer written against
163
+ * one-tool-per-step keeps reading exactly what it always read.
164
+ */
121
165
  tool_name?: string;
166
+ /** The first call's arguments. Same covenant as `tool_name`. */
122
167
  tool_params?: Record<string, unknown>;
168
+ /**
169
+ * ALL the calls of this step, in the order the model emitted them.
170
+ *
171
+ * **A pure addition.** A brain that fills only `tool_name`/`tool_params`
172
+ * (every third-party brain written before this field existed, and every
173
+ * scripted brain in the suite) is normalized to a single-call step and takes
174
+ * the path it always took, character for character. A brain that fills this
175
+ * field fills the two above with `tool_calls[0]` as well — the covenant is
176
+ * one-directional: new readers may read the array, old readers never see a
177
+ * hole where the first call used to be.
178
+ */
179
+ tool_calls?: BrainToolCall[];
123
180
  reasoning?: string;
124
181
  [key: string]: unknown;
125
182
  }
@@ -253,6 +310,89 @@ export interface DocumentState {
253
310
  startedAt: number;
254
311
  updatedAt: number;
255
312
  }
313
+ /**
314
+ * Final run metadata persisted after every handle() — for ops dashboards.
315
+ *
316
+ * Declared here, with the port it belongs to, rather than inside one adapter:
317
+ * it is part of the contract every store implements, not a Redis detail. The
318
+ * Redis adapter re-exports it, so existing imports keep working unchanged.
319
+ */
320
+ export interface SessionMetadata {
321
+ iterationCount: number;
322
+ startedAt: number;
323
+ totalCostUsd: number;
324
+ status: "running" | "completed" | "failed" | "killed";
325
+ }
326
+ /**
327
+ * The session-memory PORT — conversation history, run metadata, document state.
328
+ *
329
+ * The loop talks to this interface and never to a concrete store. Inject an
330
+ * implementation via `AgentConfig.memory`; when it is omitted the loop builds
331
+ * the bundled `RedisMemory` from `AgentConfig.redis` exactly as it always has.
332
+ *
333
+ * Six functions — the surface `RedisMemory` already had, at its current
334
+ * signatures. Nothing is added speculatively: an append-only event log is a
335
+ * different contract and does not belong here.
336
+ */
337
+ export interface SessionStore {
338
+ /** Append one message to this session's conversation history. */
339
+ appendHistory(sessionId: string, entry: Message): Promise<void>;
340
+ /** The last `limit` messages, oldest-first. Default limit: 50. */
341
+ getHistory(sessionId: string, limit?: number): Promise<Message[]>;
342
+ getMetadata(sessionId: string): Promise<SessionMetadata | null>;
343
+ setMetadata(sessionId: string, meta: SessionMetadata): Promise<void>;
344
+ getDocumentState(sessionId: string): Promise<DocumentState | null>;
345
+ setDocumentState(sessionId: string, state: DocumentState): Promise<void>;
346
+ }
347
+ /**
348
+ * What a compactor decides when it decides to compact.
349
+ *
350
+ * `keepTurns` is a REQUEST, not a command: the loop hands it to
351
+ * `compactionBoundary`, which cuts only at whole turns and refuses to sever a
352
+ * tool call from its result. A compactor asking to keep zero turns of a session
353
+ * whose last turn is still unpaired gets a smaller range than it asked for, or
354
+ * none at all. That is the point of having two defences.
355
+ */
356
+ export interface CompactionDecision {
357
+ /** The text the compacted range will read as. Empty means "no compaction". */
358
+ summary: string;
359
+ /** How many trailing turns to leave verbatim. Clamped to what is safe. */
360
+ keepTurns: number;
361
+ }
362
+ /**
363
+ * The compaction seat: a port the loop consults before it derives the
364
+ * conversation, and never a trick it plays on the history array.
365
+ *
366
+ * Today a long session is trimmed by `history.slice(-maxHistoryMessages)`. That
367
+ * cut lands wherever it lands — through the middle of a tool call and its
368
+ * result, through the middle of a turn — and it is silent. A long legal case in
369
+ * nisus loses its own beginning and nothing anywhere says so. This port is the
370
+ * seat where that stops being true.
371
+ *
372
+ * Three properties are contractual:
373
+ *
374
+ * 1. **It only works with `sessionLog`.** Compaction records a `compaction`
375
+ * event; without a log there is nowhere to record it, and a summary that is
376
+ * not recorded is exactly the silent rewrite this replaces. With `memory`
377
+ * alone the port is inert and the old `slice` path runs untouched — declared,
378
+ * not an oversight.
379
+ *
380
+ * 2. **`null` means no compaction.** The overwhelmingly common answer. A port
381
+ * that returns `null` costs one call and changes nothing.
382
+ *
383
+ * 3. **A throwing port fails the turn.** It is not swallowed. A compactor that
384
+ * has quietly been failing for a month is a session that has quietly been
385
+ * losing its head for a month — the failure this exists to end, wearing a
386
+ * different hat.
387
+ *
388
+ * @param messages the conversation DERIVED from the log so far, in order.
389
+ * @param budget the budget the derived history will actually be trimmed to —
390
+ * `maxHistoryMessages` is the effective cap, already reconciled
391
+ * with the agent's iteration-derived history limit.
392
+ */
393
+ export interface CompactionPort {
394
+ maybeCompact(messages: readonly Message[], budget: ContextBudget): Promise<CompactionDecision | null>;
395
+ }
256
396
  export interface RedisConfig {
257
397
  /**
258
398
  * Redis connection URL — mini creates its own connection.
@@ -304,6 +444,68 @@ export interface AgentConfig {
304
444
  definition: string | AgentDefinition;
305
445
  brain: Brain;
306
446
  redis: RedisConfig;
447
+ /**
448
+ * Optional session-memory port. When injected, the loop routes ALL history /
449
+ * metadata / document-state access through it and never constructs a
450
+ * Redis-backed store. When omitted, the loop builds `RedisMemory` from
451
+ * `redis` exactly as before — same prefix, same TTLs, same tenant scoping —
452
+ * so every existing consumer is unaffected.
453
+ *
454
+ * `redis` stays required either way: the control bus, the session lock and
455
+ * tool dedup are separate ports and still ride on it.
456
+ */
457
+ memory?: SessionStore;
458
+ /**
459
+ * Optional session **event log** (`@msm-core/session`). Injecting it inverts
460
+ * where conversation context comes from:
461
+ *
462
+ * - **Omitted (default)** — nothing changes. Not one event is written, the
463
+ * history handed to the brain is `memory.getHistory()` exactly as before,
464
+ * and the invariant below never runs. Every existing consumer is
465
+ * bit-for-bit unaffected.
466
+ * - **Injected** — the loop writes every event as it happens (the user
467
+ * message, each step, each model request/response, every tool call and
468
+ * its result, guards, the delivered reply), and the conversation handed
469
+ * to the model is DERIVED from the log (`deriveMessages`) instead of read
470
+ * from the history array. `memory.appendHistory` keeps being written too:
471
+ * one transitional phase of dual writing, log as truth, array as
472
+ * compatibility.
473
+ *
474
+ * Two consequences worth knowing before you inject one:
475
+ *
476
+ * 1. **The derived conversation is richer.** It carries the
477
+ * `assistant`/`tool` pairs of previous turns, which the history array
478
+ * never held. That is the point of the log, not a side effect — but it
479
+ * is a real change in what the model sees.
480
+ * 2. **It is the truth, so its failures are loud.** A rejected append
481
+ * (e.g. `SeqGapError`) fails the turn rather than being swallowed: a log
482
+ * with a hole in it cannot back an audit, a resume or a replay.
483
+ *
484
+ * Isolation is the injector's, exactly as with `memory`: this port takes a
485
+ * `sessionId` and knows nothing of tenants. Whoever injects a log injects its
486
+ * tenant scoping with it.
487
+ */
488
+ sessionLog?: SessionLogPort;
489
+ /**
490
+ * Optional **compaction port** (ض١). Consulted once per turn, right before
491
+ * the conversation is derived, and only when `sessionLog` is also injected.
492
+ *
493
+ * - **Omitted (default)** — not one extra call, not one extra event. The
494
+ * conversation is derived and trimmed exactly as it was before.
495
+ * - **Omitted `sessionLog`** — the seat is inert even when this is set:
496
+ * compaction records an event, and without a log there is nothing to
497
+ * record it in. The legacy history path keeps its `slice`. Declared.
498
+ * - **Both injected** — when the port returns a summary, the loop writes a
499
+ * `compaction` event covering a safe range (`compactionBoundary`) and
500
+ * re-derives. The model then reads one summary plus the recent turns
501
+ * verbatim, and the invariant still proves that what it read came out of
502
+ * the log — because the compaction is IN the log.
503
+ *
504
+ * `createBrainCompactor` is a reference implementation that summarises with an
505
+ * injected `Brain`. The port does not require it; anything with a
506
+ * `maybeCompact` will do, including one that never calls a model at all.
507
+ */
508
+ compaction?: CompactionPort;
307
509
  /** App-provided tools — agent can only call tools listed here */
308
510
  tools: Tool[];
309
511
  guards?: GuardConfig;
@@ -343,6 +545,15 @@ export interface GuardFiredInfo {
343
545
  signal: GuardSignal;
344
546
  iteration: number;
345
547
  }
548
+ /** One streamed piece of the model's answer, with the step it belongs to (ب١). */
549
+ export interface ChunkInfo {
550
+ sessionId: string;
551
+ /** The loop iteration whose brain call produced this text. */
552
+ iteration: number;
553
+ /** The delta — not the running total. Concatenating every chunk of a step
554
+ * yields the text that step's payload carries. */
555
+ text: string;
556
+ }
346
557
  /**
347
558
  * Return value from onBeforeTool hook:
348
559
  * "proceed" — execute the tool normally
@@ -365,6 +576,18 @@ export interface AgentHooks {
365
576
  onToolCall?: (info: ToolCallInfo) => void;
366
577
  onGuard?: (info: GuardFiredInfo) => void;
367
578
  onFatalError?: (error: Error, sessionId: string) => void;
579
+ /**
580
+ * Called for each piece of model text as it arrives (ب١). Wire it to an SSE
581
+ * channel and the user stops watching a silent screen.
582
+ *
583
+ * **Setting this is what turns streaming on.** The loop passes `onChunk` down
584
+ * to the brain only when this hook exists; with no hook, `BrainRunInput`
585
+ * carries no `onChunk` and every brain takes its ordinary non-streaming path.
586
+ *
587
+ * Fire-and-forget like its siblings: a throwing hook is swallowed and the
588
+ * stream continues — a broken display must not break the run.
589
+ */
590
+ onChunk?: (info: ChunkInfo) => void;
368
591
  /**
369
592
  * Called before every tool execution (BEFORE the dedup check, so cached
370
593
  * results can never bypass the gate). Return "proceed" to execute normally,
package/dist/index.d.ts CHANGED
@@ -7,13 +7,16 @@
7
7
  * import { RedisMemory, RedisControlBus } from 'msm-mini/adapters'
8
8
  */
9
9
  export { createAgent } from "./core/loop.js";
10
+ export { createBrainCompactor } from "./core/loop.js";
11
+ export type { BrainCompactorOptions } from "./core/loop.js";
10
12
  export { createGeminiBrain } from "./brain/gemini.js";
11
13
  export { createOpenAIBrain } from "./brain/openai.js";
12
14
  export { createAnthropicBrain } from "./brain/anthropic.js";
13
15
  export { createOllamaBrain } from "./brain/ollama.js";
14
16
  export { buildBrain } from "./brain/factory.js";
15
17
  export { parseDefinition } from "./definition/parser.js";
16
- export type { Agent, AgentConfig, AgentEvent, AgentContext, AgentDefinition, AgentHooks, BeforeToolHookResult, Brain, BrainPayload, BrainRunInput, DocumentState, GateConfig, GuardConfig, GuardSignal, GuardSignalType, IterationInfo, LoopOutcome, MemoryEntry, Message, OutcomeType, OutputValidation, OutputValidator, RedisConfig, RunState, SectionInfo, TenantContext, Tool, ToolCallInfo, ToolDefinition, ToolMeta, ToolParameter, ToolResult, } from "./core/types.js";
18
+ export type { Agent, AgentConfig, AgentEvent, AgentContext, AgentDefinition, AgentHooks, BeforeToolHookResult, Brain, BrainChunk, BrainOrchestration, BrainPayload, BrainRunInput, BrainToolCall, ChunkInfo, CompactionDecision, CompactionPort, DocumentState, GateConfig, GuardConfig, GuardSignal, GuardSignalType, IterationInfo, LoopOutcome, MemoryEntry, Message, OutcomeType, OutputValidation, OutputValidator, RedisConfig, RunState, SectionInfo, SessionMetadata, SessionStore, TenantContext, Tool, ToolCallInfo, ToolDefinition, ToolMeta, ToolParameter, ToolResult, } from "./core/types.js";
19
+ export type { ContextBudget } from "./core/context-builder.js";
17
20
  export { resolveGuards, DEFAULT_GUARDS } from "./core/guards.js";
18
21
  export { scoreOutcome } from "./quality/scorer.js";
19
22
  export type { QualityScore, QualityFlag } from "./quality/scorer.js";
package/dist/index.js CHANGED
@@ -8,6 +8,8 @@
8
8
  */
9
9
  // ── Main factory ────────────────────────────────────────────────────────────
10
10
  export { createAgent } from "./core/loop.js";
11
+ // ── Compaction (ض١) — the seat, and one reference occupant ──────────────────
12
+ export { createBrainCompactor } from "./core/loop.js";
11
13
  // ── Brain factories ─────────────────────────────────────────────────────────
12
14
  export { createGeminiBrain } from "./brain/gemini.js";
13
15
  export { createOpenAIBrain } from "./brain/openai.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msm-core/mini",
3
- "version": "0.5.1",
3
+ "version": "0.8.0",
4
4
  "description": "Portable AI agent execution loop — brain-agnostic, zero embedded databases",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -31,13 +31,6 @@
31
31
  "publishConfig": {
32
32
  "access": "public"
33
33
  },
34
- "scripts": {
35
- "build": "tsc",
36
- "test": "vitest run",
37
- "test:watch": "vitest",
38
- "clean": "rm -rf dist",
39
- "prepublishOnly": "npm run build && npm test"
40
- },
41
34
  "peerDependencies": {
42
35
  "openai": ">=4.0.0",
43
36
  "@anthropic-ai/sdk": ">=0.20.0",
@@ -55,7 +48,8 @@
55
48
  }
56
49
  },
57
50
  "dependencies": {
58
- "ioredis": "^5.3.2"
51
+ "ioredis": "^5.3.2",
52
+ "@msm-core/session": "^0.2.0"
59
53
  },
60
54
  "devDependencies": {
61
55
  "@types/node": "^20.0.0",
@@ -71,5 +65,11 @@
71
65
  "portable"
72
66
  ],
73
67
  "author": "Emad Jumaah",
74
- "license": "UNLICENSED"
75
- }
68
+ "license": "UNLICENSED",
69
+ "scripts": {
70
+ "build": "tsc",
71
+ "test": "vitest run",
72
+ "test:watch": "vitest",
73
+ "clean": "rm -rf dist"
74
+ }
75
+ }