mu-core 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.
@@ -0,0 +1,430 @@
1
+ import type { ActivityBus } from './activity';
2
+ import type { ChannelRegistry } from './channel';
3
+ import type {
4
+ AgentLoopStrategy,
5
+ AgentSourceRegistry,
6
+ LifecycleHooks,
7
+ MentionProvider,
8
+ MessageBus,
9
+ MessageRenderer,
10
+ Plugin,
11
+ PluginContext,
12
+ PluginTool,
13
+ ShortcutHandler,
14
+ SlashCommand,
15
+ StatusSegment,
16
+ } from './plugin';
17
+ import type { ProviderRegistry } from './provider/registry';
18
+ import type { SessionManager } from './session';
19
+ import type { ToolDefinition } from './types/llm';
20
+ import type { UIService } from './ui';
21
+
22
+ type StatusListener = () => void;
23
+
24
+ export interface PluginRegistryOptions {
25
+ cwd: string;
26
+ config: Record<string, unknown>;
27
+ /** Host-supplied UI service. Forwarded to every plugin via `PluginContext.ui`. */
28
+ ui?: UIService;
29
+ /** Host-supplied graceful shutdown. Forwarded via `PluginContext.shutdown`. */
30
+ shutdown?: (code?: number) => Promise<void> | void;
31
+ /** Host-supplied message bus. Forwarded via `PluginContext.messages`. */
32
+ messages?: MessageBus;
33
+ /** LLM provider registry. */
34
+ providers?: ProviderRegistry;
35
+ /** Channel registry — input surfaces. */
36
+ channels?: ChannelRegistry;
37
+ /** Session manager. */
38
+ sessions?: SessionManager;
39
+ /** Activity bus for agent / tool events. */
40
+ activity?: ActivityBus;
41
+ /** Markdown agent source registry. */
42
+ agents?: AgentSourceRegistry;
43
+ }
44
+
45
+ interface RendererEntry {
46
+ plugin: string;
47
+ customType: string;
48
+ renderer: MessageRenderer;
49
+ }
50
+
51
+ interface ShortcutEntry {
52
+ plugin: string;
53
+ key: string;
54
+ handler: ShortcutHandler;
55
+ }
56
+
57
+ interface MentionEntry {
58
+ plugin: string;
59
+ trigger: string;
60
+ provider: MentionProvider;
61
+ }
62
+
63
+ /**
64
+ * Owns plugin lifecycle, dispatch, and aggregated state.
65
+ *
66
+ * Plugin loading from a path is deliberately NOT a registry concern — host
67
+ * applications (e.g. mu-coding) implement their own loaders and call
68
+ * `register()` directly. This keeps the registry focused on lifecycle/dispatch
69
+ * and free of file-system / module-resolution dependencies.
70
+ */
71
+ export class PluginRegistry {
72
+ private plugins: Map<string, Plugin> = new Map();
73
+ private context: PluginContext;
74
+ private statusSegmentsByPlugin: Map<string, StatusSegment[]> = new Map();
75
+ private statusListeners: Set<StatusListener> = new Set();
76
+ private renderers: RendererEntry[] = [];
77
+ private shortcuts: ShortcutEntry[] = [];
78
+ private mentions: MentionEntry[] = [];
79
+ private rendererListeners: Set<() => void> = new Set();
80
+ private shortcutListeners: Set<() => void> = new Set();
81
+ private mentionListeners: Set<() => void> = new Set();
82
+ private readonly providersRegistry: ProviderRegistry | undefined;
83
+
84
+ constructor(options: PluginRegistryOptions) {
85
+ this.providersRegistry = options.providers;
86
+ this.context = {
87
+ cwd: options.cwd,
88
+ config: options.config,
89
+ ui: options.ui,
90
+ shutdown: options.shutdown,
91
+ messages: options.messages,
92
+ providers: options.providers,
93
+ channels: options.channels,
94
+ sessions: options.sessions,
95
+ activity: options.activity,
96
+ agents: options.agents,
97
+ getPlugin: <T extends Plugin>(name: string) => this.plugins.get(name) as T | undefined,
98
+ };
99
+ }
100
+
101
+ async register(plugin: Plugin): Promise<void> {
102
+ if (this.plugins.has(plugin.name)) {
103
+ throw new Error(`Plugin "${plugin.name}" is already registered`);
104
+ }
105
+ this.plugins.set(plugin.name, plugin);
106
+ if (plugin.activate) {
107
+ await plugin.activate(this.buildPluginContext(plugin.name));
108
+ }
109
+ }
110
+
111
+ async unregister(name: string): Promise<void> {
112
+ const plugin = this.plugins.get(name);
113
+ if (!plugin) {
114
+ return;
115
+ }
116
+ if (plugin.deactivate) {
117
+ await plugin.deactivate();
118
+ }
119
+ this.plugins.delete(name);
120
+ if (this.statusSegmentsByPlugin.delete(name)) {
121
+ this.emitStatus();
122
+ }
123
+ this.dropPluginRegistrations(name);
124
+ }
125
+
126
+ getPlugin<T extends Plugin>(name: string): T | undefined {
127
+ return this.plugins.get(name) as T | undefined;
128
+ }
129
+
130
+ getPlugins(): Plugin[] {
131
+ return Array.from(this.plugins.values());
132
+ }
133
+
134
+ /** Provider registry handle (or `undefined` if the host didn't supply one). */
135
+ getProviders(): ProviderRegistry | undefined {
136
+ return this.providersRegistry;
137
+ }
138
+
139
+ getTools(): PluginTool[] {
140
+ const tools: PluginTool[] = [];
141
+ for (const plugin of this.plugins.values()) {
142
+ if (plugin.tools) {
143
+ tools.push(...plugin.tools);
144
+ }
145
+ }
146
+ return tools;
147
+ }
148
+
149
+ /**
150
+ * Return the tool set after every `filterTools` hook has narrowed it.
151
+ * Hooks compose by passing each plugin's output as the next input, so the
152
+ * effective set is the intersection of every plugin's allowed tools.
153
+ */
154
+ async getFilteredTools(): Promise<PluginTool[]> {
155
+ let current = this.getTools();
156
+ for (const plugin of this.plugins.values()) {
157
+ const hook = plugin.hooks?.filterTools;
158
+ if (!hook) continue;
159
+ current = await hook(current);
160
+ }
161
+ return current;
162
+ }
163
+
164
+ getToolDefinitions(): ToolDefinition[] {
165
+ return this.getTools().map((t) => t.definition);
166
+ }
167
+
168
+ /** Look up a tool by its function name, or `undefined` if no plugin registers one. */
169
+ getTool(name: string): PluginTool | undefined {
170
+ for (const plugin of this.plugins.values()) {
171
+ const tool = plugin.tools?.find((t) => t.definition.function.name === name);
172
+ if (tool) return tool;
173
+ }
174
+ return undefined;
175
+ }
176
+
177
+ async getSystemPrompts(): Promise<string[]> {
178
+ const prompts: string[] = [];
179
+ for (const plugin of this.plugins.values()) {
180
+ if (!plugin.systemPrompt) {
181
+ continue;
182
+ }
183
+ if (typeof plugin.systemPrompt === 'string') {
184
+ prompts.push(plugin.systemPrompt);
185
+ } else {
186
+ const result = await plugin.systemPrompt(this.context);
187
+ if (result) {
188
+ prompts.push(result);
189
+ }
190
+ }
191
+ }
192
+ return prompts;
193
+ }
194
+
195
+ /** Run every `transformSystemPrompt` hook in registration order. */
196
+ async applySystemPromptTransforms(prompt: string): Promise<string> {
197
+ let current = prompt;
198
+ for (const plugin of this.plugins.values()) {
199
+ const hook = plugin.hooks?.transformSystemPrompt;
200
+ if (!hook) continue;
201
+ current = await hook(current);
202
+ }
203
+ return current;
204
+ }
205
+
206
+ getHooks(): LifecycleHooks[] {
207
+ const hooks: LifecycleHooks[] = [];
208
+ for (const plugin of this.plugins.values()) {
209
+ if (plugin.hooks) {
210
+ hooks.push(plugin.hooks);
211
+ }
212
+ }
213
+ return hooks;
214
+ }
215
+
216
+ getCommands(): SlashCommand[] {
217
+ const commands: SlashCommand[] = [];
218
+ for (const plugin of this.plugins.values()) {
219
+ if (plugin.commands) {
220
+ commands.push(...plugin.commands);
221
+ }
222
+ }
223
+ return commands;
224
+ }
225
+
226
+ /** Aggregate of every plugin's most recently pushed status segments, in registration order. */
227
+ getStatusSegments(): StatusSegment[] {
228
+ const segments: StatusSegment[] = [];
229
+ for (const plugin of this.plugins.values()) {
230
+ const pluginSegments = this.statusSegmentsByPlugin.get(plugin.name);
231
+ if (pluginSegments?.length) {
232
+ segments.push(...pluginSegments);
233
+ }
234
+ }
235
+ return segments;
236
+ }
237
+
238
+ /**
239
+ * Subscribe to status segment changes. Returns an unsubscribe fn. The listener
240
+ * fires whenever any plugin pushes (or clears) its segments.
241
+ */
242
+ onStatusChange(listener: StatusListener): () => void {
243
+ this.statusListeners.add(listener);
244
+ return () => {
245
+ this.statusListeners.delete(listener);
246
+ };
247
+ }
248
+
249
+ getAgentLoop(): AgentLoopStrategy | undefined {
250
+ for (const plugin of this.plugins.values()) {
251
+ if (plugin.agentLoop) {
252
+ return plugin.agentLoop;
253
+ }
254
+ }
255
+ return undefined;
256
+ }
257
+
258
+ // ─── Renderer / Shortcut / Mention registries ─────────────────────────────
259
+
260
+ /** Renderer for `customType`. The first match wins (registration order). */
261
+ getRenderer(customType: string): MessageRenderer | undefined {
262
+ return this.renderers.find((r) => r.customType === customType)?.renderer;
263
+ }
264
+
265
+ /** Snapshot of every registered `customType → renderer`. First registration wins. */
266
+ getRenderers(): Map<string, MessageRenderer> {
267
+ const out = new Map<string, MessageRenderer>();
268
+ for (const entry of this.renderers) {
269
+ if (!out.has(entry.customType)) {
270
+ out.set(entry.customType, entry.renderer);
271
+ }
272
+ }
273
+ return out;
274
+ }
275
+
276
+ onRenderersChange(listener: () => void): () => void {
277
+ this.rendererListeners.add(listener);
278
+ return () => {
279
+ this.rendererListeners.delete(listener);
280
+ };
281
+ }
282
+
283
+ getShortcuts(): ReadonlyArray<{ key: string; handler: ShortcutHandler; plugin: string }> {
284
+ return this.shortcuts;
285
+ }
286
+
287
+ onShortcutsChange(listener: () => void): () => void {
288
+ this.shortcutListeners.add(listener);
289
+ return () => {
290
+ this.shortcutListeners.delete(listener);
291
+ };
292
+ }
293
+
294
+ getMentionProviders(): ReadonlyArray<{ trigger: string; provider: MentionProvider; plugin: string }> {
295
+ return this.mentions;
296
+ }
297
+
298
+ onMentionProvidersChange(listener: () => void): () => void {
299
+ this.mentionListeners.add(listener);
300
+ return () => {
301
+ this.mentionListeners.delete(listener);
302
+ };
303
+ }
304
+
305
+ async shutdown(): Promise<void> {
306
+ for (const name of Array.from(this.plugins.keys()).reverse()) {
307
+ await this.unregister(name);
308
+ }
309
+ }
310
+
311
+ // ─── Internal ─────────────────────────────────────────────────────────────
312
+
313
+ private buildPluginContext(pluginName: string): PluginContext {
314
+ return {
315
+ ...this.context,
316
+ registry: {
317
+ getTools: () => this.getTools(),
318
+ getFilteredTools: () => this.getFilteredTools(),
319
+ getHooks: () => this.getHooks(),
320
+ getSystemPrompts: () => this.getSystemPrompts(),
321
+ applySystemPromptTransforms: (prompt) => this.applySystemPromptTransforms(prompt),
322
+ },
323
+ setStatusLine: (segments) => this.setStatusLine(pluginName, segments),
324
+ registerMessageRenderer: (customType, renderer) => this.addRenderer(pluginName, customType, renderer),
325
+ registerShortcut: (key, handler) => this.addShortcut(pluginName, key, handler),
326
+ registerMentionProvider: (trigger, provider) => this.addMention(pluginName, trigger, provider),
327
+ // Mutates the *shared* context so subsequent plugins see this registry
328
+ // in their own `ctx.agents`. The current plugin also gets it via the
329
+ // spread above on first activate (provided host pre-set `agents` to
330
+ // some default), but the canonical use-case is "first activated plugin
331
+ // publishes; second plugin consumes".
332
+ setAgentsRegistry: (registry) => {
333
+ this.context.agents = registry;
334
+ },
335
+ };
336
+ }
337
+
338
+ private setStatusLine(pluginName: string, segments: StatusSegment[]): void {
339
+ if (segments.length === 0) {
340
+ const removed = this.statusSegmentsByPlugin.delete(pluginName);
341
+ if (removed) this.emitStatus();
342
+ return;
343
+ }
344
+ const prev = this.statusSegmentsByPlugin.get(pluginName);
345
+ if (prev && segmentsEqual(prev, segments)) {
346
+ return;
347
+ }
348
+ this.statusSegmentsByPlugin.set(pluginName, segments);
349
+ this.emitStatus();
350
+ }
351
+
352
+ private emitStatus(): void {
353
+ for (const listener of this.statusListeners) {
354
+ listener();
355
+ }
356
+ }
357
+
358
+ private addRenderer(plugin: string, customType: string, renderer: MessageRenderer): () => void {
359
+ const entry: RendererEntry = { plugin, customType, renderer };
360
+ this.renderers.push(entry);
361
+ this.emitRenderers();
362
+ return () => {
363
+ const idx = this.renderers.indexOf(entry);
364
+ if (idx >= 0) {
365
+ this.renderers.splice(idx, 1);
366
+ this.emitRenderers();
367
+ }
368
+ };
369
+ }
370
+
371
+ private addShortcut(plugin: string, key: string, handler: ShortcutHandler): () => void {
372
+ const entry: ShortcutEntry = { plugin, key, handler };
373
+ this.shortcuts.push(entry);
374
+ this.emitShortcuts();
375
+ return () => {
376
+ const idx = this.shortcuts.indexOf(entry);
377
+ if (idx >= 0) {
378
+ this.shortcuts.splice(idx, 1);
379
+ this.emitShortcuts();
380
+ }
381
+ };
382
+ }
383
+
384
+ private addMention(plugin: string, trigger: string, provider: MentionProvider): () => void {
385
+ const entry: MentionEntry = { plugin, trigger, provider };
386
+ this.mentions.push(entry);
387
+ this.emitMentions();
388
+ return () => {
389
+ const idx = this.mentions.indexOf(entry);
390
+ if (idx >= 0) {
391
+ this.mentions.splice(idx, 1);
392
+ this.emitMentions();
393
+ }
394
+ };
395
+ }
396
+
397
+ private dropPluginRegistrations(plugin: string): void {
398
+ const beforeR = this.renderers.length;
399
+ this.renderers = this.renderers.filter((r) => r.plugin !== plugin);
400
+ if (this.renderers.length !== beforeR) this.emitRenderers();
401
+
402
+ const beforeS = this.shortcuts.length;
403
+ this.shortcuts = this.shortcuts.filter((s) => s.plugin !== plugin);
404
+ if (this.shortcuts.length !== beforeS) this.emitShortcuts();
405
+
406
+ const beforeM = this.mentions.length;
407
+ this.mentions = this.mentions.filter((m) => m.plugin !== plugin);
408
+ if (this.mentions.length !== beforeM) this.emitMentions();
409
+ }
410
+
411
+ private emitRenderers(): void {
412
+ for (const fn of this.rendererListeners) fn();
413
+ }
414
+ private emitShortcuts(): void {
415
+ for (const fn of this.shortcutListeners) fn();
416
+ }
417
+ private emitMentions(): void {
418
+ for (const fn of this.mentionListeners) fn();
419
+ }
420
+ }
421
+
422
+ function segmentsEqual(a: StatusSegment[], b: StatusSegment[]): boolean {
423
+ if (a.length !== b.length) return false;
424
+ for (let i = 0; i < a.length; i++) {
425
+ if (a[i].text !== b[i].text || a[i].color !== b[i].color || a[i].dim !== b[i].dim) {
426
+ return false;
427
+ }
428
+ }
429
+ return true;
430
+ }
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { PluginRegistry } from './registry';
3
+ import { createSessionManager } from './session';
4
+ import type { ProviderConfig } from './types/llm';
5
+
6
+ const cfg: ProviderConfig = {
7
+ baseUrl: 'http://localhost:0',
8
+ maxTokens: 1,
9
+ temperature: 0,
10
+ streamTimeoutMs: 1,
11
+ };
12
+
13
+ function newSm() {
14
+ const registry = new PluginRegistry({ cwd: '/tmp', config: {} });
15
+ return { registry, sm: createSessionManager({ registry, config: cfg, model: 'test' }) };
16
+ }
17
+
18
+ describe('SessionManager basics', () => {
19
+ it('lazily creates sessions per key', () => {
20
+ const { sm } = newSm();
21
+ const a = sm.getOrCreate('one');
22
+ const b = sm.getOrCreate('one');
23
+ const c = sm.getOrCreate('two');
24
+ expect(a).toBe(b);
25
+ expect(a).not.toBe(c);
26
+ expect(sm.list()).toHaveLength(2);
27
+ });
28
+
29
+ it('getOrCreate honours initialMessages', () => {
30
+ const { sm } = newSm();
31
+ const s = sm.getOrCreate('x', { initialMessages: [{ role: 'user', content: 'seed' }] });
32
+ expect(s.getMessages()).toEqual([{ role: 'user', content: 'seed' }]);
33
+ });
34
+
35
+ it('close removes session', async () => {
36
+ const { sm } = newSm();
37
+ sm.getOrCreate('x');
38
+ await sm.close('x');
39
+ expect(sm.get('x')).toBeUndefined();
40
+ });
41
+ });
42
+
43
+ describe('Session message store', () => {
44
+ it('appendSynthetic emits messages_changed', () => {
45
+ const { sm } = newSm();
46
+ const s = sm.getOrCreate('x');
47
+ const events: number[] = [];
48
+ s.subscribe((e) => {
49
+ if (e.type === 'messages_changed') events.push(e.messages.length);
50
+ });
51
+ s.appendSynthetic({ role: 'assistant', content: 'banner' });
52
+ expect(events).toEqual([1]);
53
+ });
54
+
55
+ it('queueForNextTurn does not appear in getMessages', () => {
56
+ const { sm } = newSm();
57
+ const s = sm.getOrCreate('x');
58
+ s.queueForNextTurn({ role: 'system', content: 'inject' });
59
+ expect(s.getMessages()).toEqual([]);
60
+ });
61
+
62
+ it('setMessages replaces transcript and emits', () => {
63
+ const { sm } = newSm();
64
+ const s = sm.getOrCreate('x');
65
+ let last: number | null = null;
66
+ s.subscribe((e) => {
67
+ if (e.type === 'messages_changed') last = e.messages.length;
68
+ });
69
+ s.setMessages([
70
+ { role: 'user', content: 'a' },
71
+ { role: 'assistant', content: 'b' },
72
+ ]);
73
+ expect(s.getMessages()).toHaveLength(2);
74
+ expect(last).toBe(2);
75
+ });
76
+
77
+ it('subscribe / unsubscribe', () => {
78
+ const { sm } = newSm();
79
+ const s = sm.getOrCreate('x');
80
+ const seen: string[] = [];
81
+ const off = s.subscribe(() => seen.push('hit'));
82
+ s.appendSynthetic({ role: 'assistant', content: 'a' });
83
+ off();
84
+ s.appendSynthetic({ role: 'assistant', content: 'b' });
85
+ expect(seen).toHaveLength(1);
86
+ });
87
+ });
88
+
89
+ describe('Session.runTurn re-entrance guard', () => {
90
+ it('rejects concurrent runTurn calls', async () => {
91
+ const { sm } = newSm();
92
+ const s = sm.getOrCreate('x');
93
+ const first = s.runTurn({ userMessage: { role: 'user', content: 'hi' } });
94
+ await expect(s.runTurn({ userMessage: { role: 'user', content: 'bye' } })).rejects.toThrow(
95
+ /already running a turn/i,
96
+ );
97
+ await first;
98
+ });
99
+ });