mu-core 0.13.0 → 0.16.1

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/README.md DELETED
@@ -1,110 +0,0 @@
1
- # mu-core
2
-
3
- The mu plugin SDK. Provides the agent loop, plugin registry, LLM types, and
4
- the multi-host primitives (channels, sessions, activity bus, providers).
5
- Provider implementations are separate packages — for OpenAI-compatible APIs,
6
- add `mu-openai-provider` and register its plugin alongside.
7
-
8
- ## Install
9
-
10
- ```bash
11
- npm install mu-core mu-openai-provider
12
- ```
13
-
14
- ## Usage
15
-
16
- ```ts
17
- import { runAgent, PluginRegistry } from "mu-core";
18
- import type { ChatMessage, ProviderConfig } from "mu-core";
19
- import { createOpenAIProviderPlugin } from "mu-openai-provider";
20
-
21
- const config: ProviderConfig = {
22
- baseUrl: "http://localhost:11434/v1",
23
- maxTokens: 4096,
24
- temperature: 0.7,
25
- streamTimeoutMs: 30000,
26
- // providerId defaults to 'openai' — register at least one provider
27
- // implementation (e.g. via createOpenAIProviderPlugin) before running.
28
- };
29
-
30
- const registry = new PluginRegistry({ cwd: process.cwd(), config: {} });
31
- await registry.register(createOpenAIProviderPlugin());
32
-
33
- const messages: ChatMessage[] = [
34
- { role: "user", content: "Hello" },
35
- ];
36
-
37
- const controller = new AbortController();
38
-
39
- for await (const event of runAgent(messages, config, "qwen2.5", controller.signal, registry)) {
40
- if (event.type === "content") process.stdout.write(event.text);
41
- }
42
- ```
43
-
44
- For a higher-level API that owns conversation state, channel I/O, and
45
- multi-session lifecycle, see `startMu` and `Session` (`createSessionManager`).
46
-
47
- ## Plugin System
48
-
49
- Plugins can provide tools, system prompts, lifecycle hooks, slash commands,
50
- custom agent loops, and side-channel registries (channels, providers,
51
- activity bus, agent sources).
52
-
53
- ```ts
54
- import type { Plugin } from "mu-core";
55
-
56
- const myPlugin: Plugin = {
57
- name: "my-plugin",
58
- tools: [
59
- {
60
- definition: {
61
- type: "function",
62
- function: {
63
- name: "hello",
64
- description: "Say hello",
65
- parameters: { type: "object", properties: {} },
66
- },
67
- },
68
- execute: async () => "Hello, world!",
69
- },
70
- ],
71
- hooks: {
72
- beforeLlmCall: (messages, config) => messages,
73
- afterLlmCall: (result) => result,
74
- beforeToolExec: (toolCall) => toolCall,
75
- afterToolExec: (toolCall, result) => result,
76
- },
77
- };
78
-
79
- await registry.register(myPlugin);
80
- ```
81
-
82
- Filesystem and shell tools live in `mu-coding` (`createCodingToolsPlugin`),
83
- not in mu-core — keeps the SDK host-agnostic.
84
-
85
- ## API
86
-
87
- ### Agent loop
88
- - `runAgent(messages, config, model, signal, registry)` — async generator yielding `AgentEvent` (`content`, `reasoning`, `messages`, `usage`, `turn_end`).
89
- - Provider resolution: looks up `config.providerId ?? 'openai'` in the registered `ProviderRegistry`. Throws if no provider is registered.
90
-
91
- ### Sessions
92
- - `createSessionManager({ registry, config, model })` returns a `SessionManager`.
93
- - `session.runTurn({ userMessage, ... })` — appends, drains queue, runs agent loop, emits events.
94
- - `session.subscribe(listener)` — `messages_changed`, `stream_partial`, `stream_started`, `stream_ended`, `usage`, `error`.
95
-
96
- ### Channels
97
- - `Channel` interface (`id`, `start`, `stop`) — input surfaces (TUI, Telegram, websocket).
98
- - `createChannelRegistry()` — host-managed registry; `startAll()` / `stopAll()` for lifecycle.
99
-
100
- ### Providers
101
- - `ProviderAdapter` + `createProvider(adapter)` — build a `Provider` from raw HTTP semantics.
102
- - `readSSE`, `readNDJSON`, `fetchWithIdleTimeout` — transport primitives.
103
- - `ProviderRegistry` — host-managed; populated by provider plugins.
104
-
105
- ### Host
106
- - `startMu(options)` — generic bootstrap: loads config, builds registries, activates plugins (config-listed via `options.resolvePlugin`, then code-passed), starts channels.
107
-
108
- ## License
109
-
110
- MIT
@@ -1,44 +0,0 @@
1
- import { describe, expect, it } from 'bun:test';
2
- import { createActivityBus } from './activity';
3
-
4
- describe('ActivityBus', () => {
5
- it('emits events to multiple subscribers', () => {
6
- const bus = createActivityBus();
7
- const a: string[] = [];
8
- const b: string[] = [];
9
- bus.subscribe((e) => a.push(e.summary));
10
- bus.subscribe((e) => b.push(e.summary));
11
- bus.emit('tool_start', 'bash', 'running git status');
12
- expect(a).toEqual(['running git status']);
13
- expect(b).toEqual(['running git status']);
14
- });
15
-
16
- it('unsubscribes', () => {
17
- const bus = createActivityBus();
18
- const seen: string[] = [];
19
- const off = bus.subscribe((e) => seen.push(e.summary));
20
- bus.emit('tool_start', 'bash', 'first');
21
- off();
22
- bus.emit('tool_start', 'bash', 'second');
23
- expect(seen).toEqual(['first']);
24
- });
25
-
26
- it('subagent stream is independent', () => {
27
- const bus = createActivityBus();
28
- const sub: string[] = [];
29
- bus.subscribeSubAgent((e) => sub.push(e.kind));
30
- bus.emitSubAgent({ runId: 'r1', agentId: 'review', kind: 'invocation_start', ts: 1, data: {} });
31
- expect(sub).toEqual(['invocation_start']);
32
- });
33
-
34
- it('throwing listener does not break the bus', () => {
35
- const bus = createActivityBus();
36
- bus.subscribe(() => {
37
- throw new Error('boom');
38
- });
39
- const seen: string[] = [];
40
- bus.subscribe((e) => seen.push(e.summary));
41
- bus.emit('tool_end', 'bash', 'ok');
42
- expect(seen).toEqual(['ok']);
43
- });
44
- });
package/src/activity.ts DELETED
@@ -1,83 +0,0 @@
1
- /**
2
- * ActivityBus — pub/sub for high-level events emitted by the agent loop and
3
- * by tools. Hosts subscribe to render timelines (TUI), broadcast to
4
- * companion websockets, etc. Independent of the session message store —
5
- * messages_changed is on Session, ActivityBus is for sidebar/observability.
6
- */
7
-
8
- export type ActivityKind =
9
- | 'agent_start'
10
- | 'agent_end'
11
- | 'tool_start'
12
- | 'tool_end'
13
- | 'task_started'
14
- | 'task_completed'
15
- | 'task_error';
16
-
17
- export interface ActivityEvent {
18
- id: number;
19
- ts: number;
20
- kind: ActivityKind;
21
- source: string;
22
- summary: string;
23
- detail?: Record<string, unknown>;
24
- }
25
-
26
- export type SubAgentEventKind =
27
- | 'invocation_start'
28
- | 'text_delta'
29
- | 'message_end'
30
- | 'tool_call_start'
31
- | 'tool_call_end'
32
- | 'invocation_end';
33
-
34
- export interface SubAgentEvent {
35
- runId: string;
36
- parentRunId?: string;
37
- agentId: string;
38
- kind: SubAgentEventKind;
39
- ts: number;
40
- data: Record<string, unknown>;
41
- }
42
-
43
- export interface ActivityBus {
44
- subscribe: (fn: (e: ActivityEvent) => void) => () => void;
45
- emit: (kind: ActivityKind, source: string, summary: string, detail?: Record<string, unknown>) => void;
46
- subscribeSubAgent: (fn: (e: SubAgentEvent) => void) => () => void;
47
- emitSubAgent: (e: SubAgentEvent) => void;
48
- }
49
-
50
- export function createActivityBus(): ActivityBus {
51
- let nextId = 1;
52
- const listeners = new Set<(e: ActivityEvent) => void>();
53
- const subListeners = new Set<(e: SubAgentEvent) => void>();
54
- return {
55
- subscribe(fn) {
56
- listeners.add(fn);
57
- return () => listeners.delete(fn);
58
- },
59
- emit(kind, source, summary, detail) {
60
- const event: ActivityEvent = { id: nextId++, ts: Date.now(), kind, source, summary, detail };
61
- for (const fn of listeners) {
62
- try {
63
- fn(event);
64
- } catch {
65
- // listeners must not break the bus
66
- }
67
- }
68
- },
69
- subscribeSubAgent(fn) {
70
- subListeners.add(fn);
71
- return () => subListeners.delete(fn);
72
- },
73
- emitSubAgent(e) {
74
- for (const fn of subListeners) {
75
- try {
76
- fn(e);
77
- } catch {
78
- // ignore
79
- }
80
- }
81
- },
82
- };
83
- }
@@ -1,52 +0,0 @@
1
- import { describe, expect, it } from 'bun:test';
2
- import { type Channel, createChannelRegistry } from './channel';
3
-
4
- function makeChannel(id: string, started: { value: boolean }): Channel {
5
- return {
6
- id,
7
- async start() {
8
- started.value = true;
9
- },
10
- async stop() {
11
- started.value = false;
12
- },
13
- };
14
- }
15
-
16
- describe('ChannelRegistry', () => {
17
- it('registers, lists, gets', () => {
18
- const r = createChannelRegistry();
19
- const flag = { value: false };
20
- r.register(makeChannel('tui', flag));
21
- expect(r.list().map((c) => c.id)).toEqual(['tui']);
22
- expect(r.get('tui')?.id).toBe('tui');
23
- expect(r.get('missing')).toBeUndefined();
24
- });
25
-
26
- it('rejects duplicate id', () => {
27
- const r = createChannelRegistry();
28
- r.register(makeChannel('a', { value: false }));
29
- expect(() => r.register(makeChannel('a', { value: false }))).toThrow();
30
- });
31
-
32
- it('startAll / stopAll', async () => {
33
- const r = createChannelRegistry();
34
- const f1 = { value: false };
35
- const f2 = { value: false };
36
- r.register(makeChannel('a', f1));
37
- r.register(makeChannel('b', f2));
38
- await r.startAll();
39
- expect(f1.value).toBe(true);
40
- expect(f2.value).toBe(true);
41
- await r.stopAll();
42
- expect(f1.value).toBe(false);
43
- expect(f2.value).toBe(false);
44
- });
45
-
46
- it('unregister callback removes', () => {
47
- const r = createChannelRegistry();
48
- const off = r.register(makeChannel('a', { value: false }));
49
- off();
50
- expect(r.list()).toEqual([]);
51
- });
52
- });
package/src/channel.ts DELETED
@@ -1,77 +0,0 @@
1
- /**
2
- * Channel — input surface for sessions. A channel converts an external
3
- * trigger (TUI keystroke, Telegram message, voice transcription, websocket
4
- * frame) into an `InboundMessage` and forwards it to a `Session`.
5
- *
6
- * The abstraction is intentionally tiny: a channel knows how to start, stop,
7
- * and (optionally) respond. The mu-core host owns lifecycle; channels are
8
- * registered via `PluginContext.channels?.register(...)`.
9
- */
10
-
11
- export type InboundKind = 'text' | 'audio';
12
- export type ResponseMode = 'text' | 'voice';
13
-
14
- export interface InboundMessage {
15
- kind: InboundKind;
16
- channelId: string;
17
- sessionId: string;
18
- messageId?: string;
19
- userId?: string;
20
- userName?: string;
21
- text?: string;
22
- responseMode?: ResponseMode;
23
- audio?: { url?: string; mimeType?: string; filePath?: string };
24
- raw?: unknown;
25
- }
26
-
27
- export interface ChannelResponder {
28
- sendText: (text: string) => Promise<void>;
29
- sendVoice?: (text: string) => Promise<void>;
30
- sendAck?: (text: string) => Promise<void>;
31
- sendError?: (text: string) => Promise<void>;
32
- }
33
-
34
- export interface Channel {
35
- id: string;
36
- start: () => Promise<void>;
37
- stop?: () => Promise<void>;
38
- }
39
-
40
- export interface ChannelRegistry {
41
- register: (channel: Channel) => () => void;
42
- list: () => Channel[];
43
- get: (id: string) => Channel | undefined;
44
- startAll: () => Promise<void>;
45
- stopAll: () => Promise<void>;
46
- }
47
-
48
- export function createChannelRegistry(): ChannelRegistry {
49
- const channels = new Map<string, Channel>();
50
- return {
51
- register(channel) {
52
- if (channels.has(channel.id)) {
53
- throw new Error(`Channel already registered: ${channel.id}`);
54
- }
55
- channels.set(channel.id, channel);
56
- return () => {
57
- channels.delete(channel.id);
58
- };
59
- },
60
- list() {
61
- return Array.from(channels.values());
62
- },
63
- get(id) {
64
- return channels.get(id);
65
- },
66
- async startAll() {
67
- for (const c of channels.values()) {
68
- await c.start();
69
- }
70
- },
71
- async stopAll() {
72
- for (const c of channels.values()) {
73
- if (c.stop) await c.stop();
74
- }
75
- },
76
- };
77
- }
package/src/hooks.test.ts DELETED
@@ -1,105 +0,0 @@
1
- import { describe, expect, it } from 'bun:test';
2
- import { runBeforeToolExecHook, runTransformUserInputHooks } from './hooks';
3
- import type { LifecycleHooks } from './plugin';
4
-
5
- const call = { id: '1', function: { name: 'foo', arguments: '{}' } };
6
-
7
- describe('runBeforeToolExecHook', () => {
8
- it('returns the call unchanged when no hook intervenes', async () => {
9
- const result = await runBeforeToolExecHook([], call);
10
- expect(result).toEqual(call);
11
- });
12
-
13
- it('lets later hooks transform the call', async () => {
14
- const hooks: LifecycleHooks[] = [
15
- {
16
- beforeToolExec: (c) => ({ ...c, function: { ...c.function, name: 'bar' } }),
17
- },
18
- {
19
- beforeToolExec: (c) =>
20
- 'blocked' in c ? c : { ...c, function: { ...c.function, name: c.function.name.toUpperCase() } },
21
- },
22
- ];
23
- const result = await runBeforeToolExecHook(hooks, call);
24
- expect('blocked' in result).toBe(false);
25
- if (!('blocked' in result)) {
26
- expect(result.function.name).toBe('BAR');
27
- }
28
- });
29
-
30
- it('short-circuits on first block', async () => {
31
- let secondCalled = false;
32
- const hooks: LifecycleHooks[] = [
33
- { beforeToolExec: () => ({ blocked: true, content: 'denied', error: true }) },
34
- {
35
- beforeToolExec: (c) => {
36
- secondCalled = true;
37
- return c;
38
- },
39
- },
40
- ];
41
- const result = await runBeforeToolExecHook(hooks, call);
42
- expect('blocked' in result && result.content).toBe('denied');
43
- expect(secondCalled).toBe(false);
44
- });
45
- });
46
-
47
- describe('runTransformUserInputHooks', () => {
48
- it('returns pass when no hook intervenes', async () => {
49
- expect(await runTransformUserInputHooks([], 'hello')).toEqual({ kind: 'pass' });
50
- });
51
-
52
- it('threads transformed text through subsequent hooks', async () => {
53
- const hooks: LifecycleHooks[] = [
54
- { transformUserInput: (t) => ({ kind: 'transform', text: `[a]${t}` }) },
55
- { transformUserInput: (t) => ({ kind: 'transform', text: `${t}[b]` }) },
56
- ];
57
- const result = await runTransformUserInputHooks(hooks, 'X');
58
- expect(result.kind === 'transform' && result.text).toBe('[a]X[b]');
59
- });
60
-
61
- it('intercept short-circuits the chain', async () => {
62
- let secondCalled = false;
63
- const hooks: LifecycleHooks[] = [
64
- { transformUserInput: () => ({ kind: 'intercept' }) },
65
- {
66
- transformUserInput: () => {
67
- secondCalled = true;
68
- return { kind: 'pass' };
69
- },
70
- },
71
- ];
72
- const result = await runTransformUserInputHooks(hooks, 'X');
73
- expect(result.kind).toBe('intercept');
74
- expect(secondCalled).toBe(false);
75
- });
76
-
77
- it('propagates continue to the caller', async () => {
78
- // Regression: the composer used to silently drop `continue`, falling
79
- // back to `pass`. That made the host re-push the user message on top
80
- // of the one a plugin (mu-agents @-mention dispatch) had already
81
- // appended, producing a duplicate user bubble in the transcript.
82
- const hooks: LifecycleHooks[] = [{ transformUserInput: () => ({ kind: 'continue' }) }];
83
- const result = await runTransformUserInputHooks(hooks, 'X');
84
- expect(result.kind).toBe('continue');
85
- });
86
-
87
- it('continue short-circuits the chain', async () => {
88
- // Once a plugin has appended the user message itself, downstream
89
- // hooks can't safely transform absent text — same chain-termination
90
- // semantics as `intercept`.
91
- let secondCalled = false;
92
- const hooks: LifecycleHooks[] = [
93
- { transformUserInput: () => ({ kind: 'continue' }) },
94
- {
95
- transformUserInput: () => {
96
- secondCalled = true;
97
- return { kind: 'transform', text: 'should-not-apply' };
98
- },
99
- },
100
- ];
101
- const result = await runTransformUserInputHooks(hooks, 'X');
102
- expect(result.kind).toBe('continue');
103
- expect(secondCalled).toBe(false);
104
- });
105
- });
package/src/hooks.ts DELETED
@@ -1,112 +0,0 @@
1
- import type { AgentEndReason, BeforeToolExecResult, LifecycleHooks, TurnResult, UserInputTransform } from './plugin';
2
- import type { ChatMessage, ProviderConfig, ToolCall } from './types/llm';
3
-
4
- export async function runBeforeLlmHooks(
5
- hooks: LifecycleHooks[],
6
- messages: ChatMessage[],
7
- config: ProviderConfig,
8
- ): Promise<ChatMessage[]> {
9
- let current = messages;
10
- for (const hook of hooks) {
11
- if (hook.beforeLlmCall) {
12
- current = await hook.beforeLlmCall(current, config);
13
- }
14
- }
15
- return current;
16
- }
17
-
18
- export async function runAfterLlmHooks(hooks: LifecycleHooks[], result: TurnResult): Promise<TurnResult> {
19
- let current = result;
20
- for (const hook of hooks) {
21
- if (hook.afterLlmCall) {
22
- current = await hook.afterLlmCall(current);
23
- }
24
- }
25
- return current;
26
- }
27
-
28
- /**
29
- * Run every `beforeToolExec` hook in order. Each hook may either return a
30
- * (possibly mutated) `ToolCall` to keep the chain going, or a `ToolBlock` to
31
- * short-circuit execution. Once a hook blocks the call, no further hooks run
32
- * — there's nothing useful to forward.
33
- */
34
- export async function runBeforeToolExecHook(
35
- hooks: LifecycleHooks[],
36
- toolCall: ToolCall,
37
- ): Promise<BeforeToolExecResult> {
38
- let current: BeforeToolExecResult = toolCall;
39
- for (const hook of hooks) {
40
- if (!hook.beforeToolExec) continue;
41
- if ('blocked' in current) return current;
42
- current = await hook.beforeToolExec(current);
43
- }
44
- return current;
45
- }
46
-
47
- export async function runAfterToolExecHook(
48
- hooks: LifecycleHooks[],
49
- toolCall: ToolCall,
50
- result: string,
51
- ): Promise<string> {
52
- let current = result;
53
- for (const hook of hooks) {
54
- if (hook.afterToolExec) {
55
- current = await hook.afterToolExec(toolCall, current);
56
- }
57
- }
58
- return current;
59
- }
60
-
61
- /**
62
- * Pipe a freshly built `ChatMessage` through every `decorateMessage` hook in
63
- * order. Each hook may return a (possibly mutated) message; later hooks see
64
- * the result of the previous one. Used to stamp display hints (agent badge,
65
- * color) without coupling the host to any specific plugin.
66
- */
67
- export async function runDecorateMessageHooks(hooks: LifecycleHooks[], msg: ChatMessage): Promise<ChatMessage> {
68
- let current = msg;
69
- for (const hook of hooks) {
70
- if (hook.decorateMessage) {
71
- current = await hook.decorateMessage(current);
72
- }
73
- }
74
- return current;
75
- }
76
-
77
- export async function runAfterAgentRunHooks(hooks: LifecycleHooks[], reason: AgentEndReason): Promise<void> {
78
- for (const hook of hooks) {
79
- if (hook.afterAgentRun) {
80
- await hook.afterAgentRun(reason);
81
- }
82
- }
83
- }
84
-
85
- /**
86
- * Compose every `transformUserInput` hook. Earlier hooks see the raw text;
87
- * each subsequent hook sees the (possibly rewritten) text emitted by the
88
- * previous one. The first `intercept` or `continue` short-circuits the chain
89
- * — once a plugin has either suppressed the input or appended the user
90
- * message itself, downstream hooks can't safely keep transforming absent
91
- * text, and the host needs to see the terminating signal verbatim so it
92
- * skips its own user-message push (see `useOnSend`).
93
- */
94
- export async function runTransformUserInputHooks(hooks: LifecycleHooks[], text: string): Promise<UserInputTransform> {
95
- let current: UserInputTransform = { kind: 'pass' };
96
- let working = text;
97
- for (const hook of hooks) {
98
- if (!hook.transformUserInput) continue;
99
- const next = await hook.transformUserInput(working);
100
- if (next.kind === 'intercept') {
101
- return next;
102
- }
103
- if (next.kind === 'continue') {
104
- return next;
105
- }
106
- if (next.kind === 'transform') {
107
- working = next.text;
108
- current = next;
109
- }
110
- }
111
- return current;
112
- }