@msm-core/mini 0.9.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.
@@ -0,0 +1,119 @@
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
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
57
+ import { extname, join } from "node:path";
58
+ /** The pack marker inside a skill folder. */
59
+ const PACK_FILE = "SKILL.md";
60
+ /** The heading the block is published under. */
61
+ const BLOCK_HEADING = "## Skills";
62
+ /** What sits between two skills inside the block. */
63
+ const SEPARATOR = "\n\n---\n\n";
64
+ /**
65
+ * Code-unit order: total, locale-independent, and identical on every platform.
66
+ * `Array.prototype.sort()`'s default is also code-unit order, but it gets there
67
+ * by stringifying — spelling it out states the intent that the guard tests.
68
+ */
69
+ function byCodeUnit(a, b) {
70
+ return a < b ? -1 : a > b ? 1 : 0;
71
+ }
72
+ /**
73
+ * Read every skill under `dir` and return them as one markdown block, or `""`
74
+ * when the directory is missing, is not a directory, or holds no skills.
75
+ *
76
+ * Never throws for a skill it cannot read: the failure is reported on `log` and
77
+ * the remaining skills still load. A definition missing one section is worth
78
+ * more than an agent that will not start.
79
+ */
80
+ export function loadSkills(dir, log = {}) {
81
+ if (!existsSync(dir) || !statSync(dir).isDirectory())
82
+ return "";
83
+ // Sorted BEFORE anything is read: the order of this array is the order of the
84
+ // block, and the block is part of what the model is asked.
85
+ const entries = [...readdirSync(dir)].sort(byCodeUnit);
86
+ const parts = [];
87
+ for (const entry of entries) {
88
+ const path = join(dir, entry);
89
+ let text;
90
+ try {
91
+ const stat = statSync(path);
92
+ if (stat.isDirectory()) {
93
+ const pack = join(path, PACK_FILE);
94
+ if (!existsSync(pack))
95
+ continue; // a plain folder, not a skill pack
96
+ text = readFileSync(pack, "utf-8").trim();
97
+ }
98
+ else if (extname(entry).toLowerCase() === ".md") {
99
+ text = readFileSync(path, "utf-8").trim();
100
+ }
101
+ else {
102
+ continue; // not a skill in either shape
103
+ }
104
+ }
105
+ catch (err) {
106
+ log.warn?.(`[skills] failed to load ${entry}: ${err.message}`);
107
+ continue; // one unreadable skill must not cost the others
108
+ }
109
+ if (text === "") {
110
+ log.warn?.(`[skills] skipped empty "${entry}"`);
111
+ continue;
112
+ }
113
+ parts.push(text);
114
+ log.info?.(`[skills] loaded "${entry}"`);
115
+ }
116
+ if (parts.length === 0)
117
+ return "";
118
+ return `${BLOCK_HEADING}\n\n${parts.join(SEPARATOR)}`;
119
+ }
package/dist/index.d.ts CHANGED
@@ -15,10 +15,14 @@ export { createAnthropicBrain } from "./brain/anthropic.js";
15
15
  export { createOllamaBrain } from "./brain/ollama.js";
16
16
  export { buildBrain } from "./brain/factory.js";
17
17
  export { parseDefinition } from "./definition/parser.js";
18
+ export { loadSkills } from "./definition/skills.js";
19
+ export type { SkillsLogPort } from "./definition/skills.js";
18
20
  export { createDelegateTool, DELEGATE_TOOL_NAME } from "./tools/delegate.js";
19
21
  export type { DelegateTool, DelegateToolOptions } from "./tools/delegate.js";
20
- 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";
22
+ export type { Agent, AgentConfig, AgentEvent, AgentContext, AgentDefinition, AgentHooks, BeforeToolHookResult, Brain, BrainChunk, BrainOrchestration, BrainPayload, BrainRunInput, BrainToolCall, ChunkInfo, CompactionDecision, CompactionPort, ControlBusPort, DedupPort, DocumentState, GateConfig, GuardConfig, GuardSignal, GuardSignalType, IterationInfo, LockHandle, LoopOutcome, MemoryEntry, Message, OutcomeType, OutputValidation, OutputValidator, RedisConfig, ResetAwareChunkHook, ResetAwareChunkSink, RunLockPort, RunState, SectionInfo, SessionMetadata, SessionStore, TenantContext, Tool, ToolCallInfo, ToolDefinition, ToolMeta, ToolParameter, ToolResult, } from "./core/types.js";
21
23
  export type { ContextBudget } from "./core/context-builder.js";
24
+ export { acceptResets, isResetAware, acceptChunkResets, isResetAwareHook, } from "./core/types.js";
25
+ export { respondingModel } from "./core/types.js";
22
26
  export { resolveGuards, DEFAULT_GUARDS } from "./core/guards.js";
23
27
  export { scoreOutcome } from "./quality/scorer.js";
24
28
  export type { QualityScore, QualityFlag } from "./quality/scorer.js";
package/dist/index.js CHANGED
@@ -18,8 +18,36 @@ export { createOllamaBrain } from "./brain/ollama.js";
18
18
  export { buildBrain } from "./brain/factory.js";
19
19
  // ── Definition parser ───────────────────────────────────────────────────────
20
20
  export { parseDefinition } from "./definition/parser.js";
21
+ // ── Skills (م١) — instruction packs read from disk, composed by the host ─────
22
+ //
23
+ // `loadSkills(dir)` returns one `## Skills` markdown block; appending it to a
24
+ // definition is the composition root's line of code, not the loop's.
25
+ export { loadSkills } from "./definition/skills.js";
21
26
  // ── Delegation (ر٢) — one agent asks another, as an ordinary tool ───────────
22
27
  export { createDelegateTool, DELEGATE_TOOL_NAME } from "./tools/delegate.js";
28
+ // ── Streaming: opting in to a reset (ص٢/٢ · ص٣/٢) ───────────────────────────
29
+ //
30
+ // Two doors onto the same opt-in, one per altitude.
31
+ //
32
+ // • A consumer that drives a brain DIRECTLY wraps its chunk sink in
33
+ // `acceptResets` (ص٢/٢) and stops losing the tail of a retried answer.
34
+ // • A consumer that runs an AGENT wraps its `AgentHooks.onChunk` in
35
+ // `acceptChunkResets` (ص٣/٢), and the loop declares to the brain on its
36
+ // behalf; `ChunkInfo.reset` then arrives on the first chunk of a retried
37
+ // attempt. This is the door most people want — `onChunk` is where an SSE
38
+ // channel is actually wired.
39
+ //
40
+ // Without a wrapper nothing changes anywhere: no reset is produced, and the
41
+ // object delivered to `onChunk` keeps the exact three keys it has always had.
42
+ export { acceptResets, isResetAware, acceptChunkResets, isResetAwareHook, } from "./core/types.js";
43
+ // ── Who answered (س٦/٤) ─────────────────────────────────────────────────────
44
+ //
45
+ // The reading the four bundled brains use to fill `BrainPayload.model` from
46
+ // their provider's reply — exported so a custom brain fills it the same way
47
+ // instead of inventing a second reading that drifts. Hand it whatever the
48
+ // provider returned; it yields `{ model }` when the reply named one and `{}`
49
+ // when it did not, ready to spread into the payload.
50
+ export { respondingModel } from "./core/types.js";
23
51
  // ── Guard utilities ─────────────────────────────────────────────────────────
24
52
  export { resolveGuards, DEFAULT_GUARDS } from "./core/guards.js";
25
53
  // ── Quality scorer ──────────────────────────────────────────────────────────
@@ -5,12 +5,33 @@
5
5
  * Stored in Redis as: {prefix}:session:{id}:tools:dedup hash field
6
6
  * TTL: 5 minutes (configurable)
7
7
  */
8
- import type { ToolResult } from "../core/types.js";
8
+ import type { DedupPort, ToolResult } from "../core/types.js";
9
9
  type RedisLike = {
10
10
  hget(key: string, field: string): Promise<string | null>;
11
11
  hset(key: string, field: string, value: string): Promise<unknown>;
12
12
  expire(key: string, seconds: number): Promise<unknown>;
13
13
  };
14
+ /**
15
+ * The bundled Redis-backed `DedupPort` — the three functions below, behind the
16
+ * port the executor now takes.
17
+ *
18
+ * It adds nothing: `check`/`store` are `checkDedup`/`storeDedup` with the key
19
+ * built by `toolDedupKey` from the prefix this was constructed with, which is
20
+ * precisely what the executor used to do inline with a raw client. The class
21
+ * exists so that the executor talks to ONE thing whether the dedup came from a
22
+ * config or from Redis, and so the Redis path keeps its exact key shape and TTL
23
+ * behaviour while doing so.
24
+ *
25
+ * The prefix is the tenant-scoped one when the loop builds this
26
+ * (`{prefix}:{companyId}:{agentType}`); an injected port scopes itself.
27
+ */
28
+ export declare class RedisToolDedup implements DedupPort {
29
+ private readonly redis;
30
+ private readonly prefix;
31
+ constructor(redis: RedisLike, prefix: string);
32
+ check(sessionId: string, hash: string): Promise<ToolResult | null>;
33
+ store(sessionId: string, hash: string, result: ToolResult, ttlSeconds: number): Promise<void>;
34
+ }
14
35
  export declare function toolDedupKey(prefix: string, sessionId: string): string;
15
36
  export declare function checkDedup(redis: RedisLike, dedupKey: string, hash: string): Promise<ToolResult | null>;
16
37
  export declare function storeDedup(redis: RedisLike, dedupKey: string, hash: string, result: ToolResult, ttlSeconds: number): Promise<void>;
@@ -6,6 +6,34 @@
6
6
  * TTL: 5 minutes (configurable)
7
7
  */
8
8
  import { createHash } from "node:crypto";
9
+ /**
10
+ * The bundled Redis-backed `DedupPort` — the three functions below, behind the
11
+ * port the executor now takes.
12
+ *
13
+ * It adds nothing: `check`/`store` are `checkDedup`/`storeDedup` with the key
14
+ * built by `toolDedupKey` from the prefix this was constructed with, which is
15
+ * precisely what the executor used to do inline with a raw client. The class
16
+ * exists so that the executor talks to ONE thing whether the dedup came from a
17
+ * config or from Redis, and so the Redis path keeps its exact key shape and TTL
18
+ * behaviour while doing so.
19
+ *
20
+ * The prefix is the tenant-scoped one when the loop builds this
21
+ * (`{prefix}:{companyId}:{agentType}`); an injected port scopes itself.
22
+ */
23
+ export class RedisToolDedup {
24
+ redis;
25
+ prefix;
26
+ constructor(redis, prefix) {
27
+ this.redis = redis;
28
+ this.prefix = prefix;
29
+ }
30
+ async check(sessionId, hash) {
31
+ return checkDedup(this.redis, toolDedupKey(this.prefix, sessionId), hash);
32
+ }
33
+ async store(sessionId, hash, result, ttlSeconds) {
34
+ await storeDedup(this.redis, toolDedupKey(this.prefix, sessionId), hash, result, ttlSeconds);
35
+ }
36
+ }
9
37
  export function toolDedupKey(prefix, sessionId) {
10
38
  return `${prefix}:session:${sessionId}:tools:dedup`;
11
39
  }
@@ -10,26 +10,70 @@
10
10
  * 4. Execute — call tool.execute()
11
11
  * 5. Cache — store result in dedup hash
12
12
  */
13
- import type { Tool, ToolResult, ToolMeta, AgentHooks } from "../core/types.js";
13
+ import type { Tool, ToolResult, ToolMeta, AgentHooks, DedupPort } from "../core/types.js";
14
14
  type RedisLike = {
15
15
  hget(key: string, field: string): Promise<string | null>;
16
16
  hset(key: string, field: string, value: string): Promise<unknown>;
17
17
  expire(key: string, seconds: number): Promise<unknown>;
18
18
  get(key: string): Promise<string | null>;
19
19
  };
20
- export interface ExecutorOptions {
20
+ /**
21
+ * Where step 3's cache lives — a port, or a Redis client to build one from.
22
+ *
23
+ * A union rather than "a port and an optional client", so the compiler still
24
+ * insists on exactly one answer. The second arm is the call this function has
25
+ * always taken (`{ redis, redisPrefix, … }`) and it keeps working character for
26
+ * character; the loop passes the first, because since س٦ it resolves the dedup
27
+ * port itself — injected or Redis-backed — and hands one thing down.
28
+ */
29
+ type DedupSource = {
30
+ dedup: DedupPort;
31
+ redis?: never;
32
+ redisPrefix?: never;
33
+ } | {
34
+ dedup?: never;
21
35
  redis: RedisLike;
22
36
  redisPrefix: string;
37
+ };
38
+ export type ExecutorOptions = DedupSource & {
23
39
  dedupTtlSeconds: number;
24
40
  /** Optional hooks — only onBeforeTool is used at this layer */
25
41
  hooks?: Pick<AgentHooks, "onBeforeTool">;
26
- }
42
+ };
27
43
  export interface ExecutionResult {
28
44
  result: ToolResult;
29
45
  cached: boolean;
30
46
  durationMs: number;
31
47
  }
32
48
  export declare function executeTool(tool: Tool, params: Record<string, unknown>, meta: ToolMeta, opts: ExecutorOptions): Promise<ExecutionResult>;
33
- /** Build a ToolDefinition list from app-provided Tool objects (for brain prompt) */
34
- export declare function toToolDefinitions(tools: Tool[]): import("../core/types.js").ToolDefinition[];
49
+ /**
50
+ * Build a ToolDefinition list from app-provided Tool objects (for brain prompt).
51
+ *
52
+ * **The stamp travels (ر١/١).** `ToolDefinition` has declared `destructive` and
53
+ * `category` since before MCP existed and nothing ever filled them: a tool an
54
+ * operator stamped `destructive` at the composition root reached the model
55
+ * looking exactly like a read-only one. This was never a safety hole — the
56
+ * executor enforces `requiresApproval` above regardless of what the model was
57
+ * told — but a model that is never shown which of its tools change the world
58
+ * cannot be asked to be careful with them, and the two fields existed on the
59
+ * type precisely so that it could be.
60
+ *
61
+ * **Why the parameter widens instead of the body casting.** Mini's `Tool` does
62
+ * not declare the two fields; `McpTool` (ر١) and `DelegateTool` (ر٢) each add
63
+ * them to a plain `Tool`, and both reach here through `AgentConfig.tools:
64
+ * Tool[]` — on the object, off the type. Saying so in the signature keeps it
65
+ * honest: a plain `Tool[]` still satisfies it (both fields are optional), a
66
+ * stamped array is read with no `as` anywhere, and a caller who writes
67
+ * `destructive: "yes"` is told by the compiler instead of dropped in silence.
68
+ *
69
+ * **Absent stays absent.** The conditional spreads are not style: under
70
+ * `exactOptionalPropertyTypes` an unstamped tool must produce the object it
71
+ * produced yesterday, key for key. That is what keeps an unstamped run's
72
+ * `@msm-core/replay` fingerprint unmoved — only a tool that actually carries a
73
+ * stamp changes what the model is asked, and only its fingerprint moves.
74
+ */
75
+ export declare function toToolDefinitions(tools: ReadonlyArray<Tool & {
76
+ destructive?: boolean;
77
+ category?: string;
78
+ }>): import("../core/types.js").ToolDefinition[];
35
79
  export {};
@@ -10,7 +10,7 @@
10
10
  * 4. Execute — call tool.execute()
11
11
  * 5. Cache — store result in dedup hash
12
12
  */
13
- import { hashToolCall, checkDedup, storeDedup, toolDedupKey } from "./dedup.js";
13
+ import { hashToolCall, RedisToolDedup } from "./dedup.js";
14
14
  export async function executeTool(tool, params, meta, opts) {
15
15
  const start = Date.now();
16
16
  // Step 1: Validate required parameters
@@ -95,9 +95,15 @@ export async function executeTool(tool, params, meta, opts) {
95
95
  }
96
96
  // Step 3: Dedup check (keyed on the EFFECTIVE params, so edited-approval runs
97
97
  // are not served a cached result for the original params).
98
+ //
99
+ // The hash stays here and stays pure — it is not I/O and there is nothing to
100
+ // swap about it, so every implementation of the port dedups on exactly the
101
+ // same key the Redis one always did.
102
+ const dedup = opts.dedup
103
+ ? opts.dedup
104
+ : new RedisToolDedup(opts.redis, opts.redisPrefix);
98
105
  const hash = hashToolCall(tool.name, effectiveParams);
99
- const dedupKey = toolDedupKey(opts.redisPrefix, meta.sessionId);
100
- const cached = await checkDedup(opts.redis, dedupKey, hash);
106
+ const cached = await dedup.check(meta.sessionId, hash);
101
107
  if (cached) {
102
108
  return { result: cached, cached: true, durationMs: Date.now() - start };
103
109
  }
@@ -115,7 +121,7 @@ export async function executeTool(tool, params, meta, opts) {
115
121
  }
116
122
  // Step 5: Cache successful results only
117
123
  if (result.status === "ok") {
118
- await storeDedup(opts.redis, dedupKey, hash, result, opts.dedupTtlSeconds);
124
+ await dedup.store(meta.sessionId, hash, result, opts.dedupTtlSeconds);
119
125
  }
120
126
  return { result, cached: false, durationMs: Date.now() - start };
121
127
  }
@@ -135,7 +141,32 @@ function validateParams(tool, params) {
135
141
  }
136
142
  return null;
137
143
  }
138
- /** Build a ToolDefinition list from app-provided Tool objects (for brain prompt) */
144
+ /**
145
+ * Build a ToolDefinition list from app-provided Tool objects (for brain prompt).
146
+ *
147
+ * **The stamp travels (ر١/١).** `ToolDefinition` has declared `destructive` and
148
+ * `category` since before MCP existed and nothing ever filled them: a tool an
149
+ * operator stamped `destructive` at the composition root reached the model
150
+ * looking exactly like a read-only one. This was never a safety hole — the
151
+ * executor enforces `requiresApproval` above regardless of what the model was
152
+ * told — but a model that is never shown which of its tools change the world
153
+ * cannot be asked to be careful with them, and the two fields existed on the
154
+ * type precisely so that it could be.
155
+ *
156
+ * **Why the parameter widens instead of the body casting.** Mini's `Tool` does
157
+ * not declare the two fields; `McpTool` (ر١) and `DelegateTool` (ر٢) each add
158
+ * them to a plain `Tool`, and both reach here through `AgentConfig.tools:
159
+ * Tool[]` — on the object, off the type. Saying so in the signature keeps it
160
+ * honest: a plain `Tool[]` still satisfies it (both fields are optional), a
161
+ * stamped array is read with no `as` anywhere, and a caller who writes
162
+ * `destructive: "yes"` is told by the compiler instead of dropped in silence.
163
+ *
164
+ * **Absent stays absent.** The conditional spreads are not style: under
165
+ * `exactOptionalPropertyTypes` an unstamped tool must produce the object it
166
+ * produced yesterday, key for key. That is what keeps an unstamped run's
167
+ * `@msm-core/replay` fingerprint unmoved — only a tool that actually carries a
168
+ * stamp changes what the model is asked, and only its fingerprint moves.
169
+ */
139
170
  export function toToolDefinitions(tools) {
140
171
  return tools.map((t) => ({
141
172
  name: t.name,
@@ -144,5 +175,7 @@ export function toToolDefinitions(tools) {
144
175
  ...(t.requiresApproval !== undefined
145
176
  ? { requiresApproval: t.requiresApproval }
146
177
  : {}),
178
+ ...(t.destructive !== undefined ? { destructive: t.destructive } : {}),
179
+ ...(t.category !== undefined ? { category: t.category } : {}),
147
180
  }));
148
181
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msm-core/mini",
3
- "version": "0.9.0",
3
+ "version": "0.14.0",
4
4
  "description": "Portable AI agent execution loop — brain-agnostic, zero embedded databases",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -49,7 +49,7 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "ioredis": "^5.3.2",
52
- "@msm-core/session": "^0.2.0"
52
+ "@msm-core/session": "^0.3.0"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@types/node": "^20.0.0",
@@ -68,7 +68,8 @@
68
68
  "license": "UNLICENSED",
69
69
  "scripts": {
70
70
  "build": "tsc",
71
- "test": "vitest run",
71
+ "typecheck": "tsc -p tsconfig.test.json",
72
+ "test": "tsc -p tsconfig.test.json && vitest run",
72
73
  "test:watch": "vitest",
73
74
  "clean": "rm -rf dist"
74
75
  }