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.
- package/README.md +110 -0
- package/package.json +14 -0
- package/src/activity.test.ts +44 -0
- package/src/activity.ts +83 -0
- package/src/agent.ts +238 -0
- package/src/channel.test.ts +52 -0
- package/src/channel.ts +77 -0
- package/src/hooks.test.ts +76 -0
- package/src/hooks.ts +89 -0
- package/src/host/index.ts +135 -0
- package/src/host/startMu.test.ts +66 -0
- package/src/index.ts +73 -0
- package/src/plugin.ts +337 -0
- package/src/provider/adapter.ts +100 -0
- package/src/provider/registry.test.ts +37 -0
- package/src/provider/registry.ts +26 -0
- package/src/provider/transport.test.ts +58 -0
- package/src/provider/transport.ts +103 -0
- package/src/registry.context.test.ts +71 -0
- package/src/registry.ts +430 -0
- package/src/session.test.ts +99 -0
- package/src/session.ts +227 -0
- package/src/types/llm.ts +107 -0
- package/src/ui.ts +49 -0
package/src/session.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session — owns the message history for a conversation context, runs the
|
|
3
|
+
* agent loop on submit, and emits events to subscribers (TUI, persistence,
|
|
4
|
+
* HTTP relay, …).
|
|
5
|
+
*
|
|
6
|
+
* Multi-session: channels emit a `sessionId`; SessionManager lazily
|
|
7
|
+
* instantiates a Session per key. mu-coding uses 'tui'; Arya uses
|
|
8
|
+
* `telegram:${chatId}`, etc.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { runAgent } from './agent';
|
|
12
|
+
import type { ChannelResponder, InboundMessage } from './channel';
|
|
13
|
+
import type { PluginRegistry } from './registry';
|
|
14
|
+
import type { ChatMessage, ProviderConfig } from './types/llm';
|
|
15
|
+
|
|
16
|
+
export type SessionEvent =
|
|
17
|
+
| { type: 'messages_changed'; messages: ChatMessage[] }
|
|
18
|
+
| { type: 'stream_partial'; text: string; reasoning?: string }
|
|
19
|
+
| { type: 'stream_started' }
|
|
20
|
+
| { type: 'stream_ended' }
|
|
21
|
+
| { type: 'usage'; totalTokens: number; cachedTokens: number }
|
|
22
|
+
| { type: 'error'; message: string };
|
|
23
|
+
|
|
24
|
+
export interface RunTurnOptions {
|
|
25
|
+
/** Pre-built user message to append before running the agent loop. */
|
|
26
|
+
userMessage: ChatMessage;
|
|
27
|
+
/** Override config for this single turn (e.g. fresh model id). */
|
|
28
|
+
config?: ProviderConfig;
|
|
29
|
+
/** Override model for this single turn. */
|
|
30
|
+
model?: string;
|
|
31
|
+
/** Override registry for this single turn (rare). */
|
|
32
|
+
registry?: PluginRegistry;
|
|
33
|
+
/**
|
|
34
|
+
* Synthetic messages already in state that should NOT be re-appended (the
|
|
35
|
+
* caller updated React state imperatively). When omitted, Session works
|
|
36
|
+
* off its own tracked transcript.
|
|
37
|
+
*/
|
|
38
|
+
baseMessages?: ChatMessage[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Session {
|
|
42
|
+
readonly id: string;
|
|
43
|
+
getMessages: () => ChatMessage[];
|
|
44
|
+
setMessages: (messages: ChatMessage[]) => void;
|
|
45
|
+
submit: (input: InboundMessage, responder: ChannelResponder) => Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Lower-level entry point used by hosts that pre-process the user input
|
|
48
|
+
* (transformUserInput hooks, attachments). Appends the message, drains
|
|
49
|
+
* the next-turn queue, runs the agent loop, and emits events.
|
|
50
|
+
*/
|
|
51
|
+
runTurn: (options: RunTurnOptions) => Promise<ChatMessage[] | null>;
|
|
52
|
+
abort: () => void;
|
|
53
|
+
appendSynthetic: (msg: ChatMessage) => void;
|
|
54
|
+
queueForNextTurn: (msg: ChatMessage) => void;
|
|
55
|
+
subscribe: (listener: (event: SessionEvent) => void) => () => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SessionInit {
|
|
59
|
+
initialMessages?: ChatMessage[];
|
|
60
|
+
systemPrompt?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SessionManager {
|
|
64
|
+
getOrCreate: (key: string, init?: SessionInit) => Session;
|
|
65
|
+
get: (key: string) => Session | undefined;
|
|
66
|
+
list: () => Session[];
|
|
67
|
+
close: (key: string) => Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CreateSessionManagerOptions {
|
|
71
|
+
registry: PluginRegistry;
|
|
72
|
+
config: ProviderConfig;
|
|
73
|
+
model: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
class SessionImpl implements Session {
|
|
77
|
+
readonly id: string;
|
|
78
|
+
private messages: ChatMessage[] = [];
|
|
79
|
+
private queue: ChatMessage[] = [];
|
|
80
|
+
private listeners = new Set<(e: SessionEvent) => void>();
|
|
81
|
+
private abortCtl: AbortController | null = null;
|
|
82
|
+
private systemPrompt?: string;
|
|
83
|
+
|
|
84
|
+
constructor(
|
|
85
|
+
id: string,
|
|
86
|
+
private registry: PluginRegistry,
|
|
87
|
+
private config: ProviderConfig,
|
|
88
|
+
private model: string,
|
|
89
|
+
init?: SessionInit,
|
|
90
|
+
) {
|
|
91
|
+
this.id = id;
|
|
92
|
+
this.systemPrompt = init?.systemPrompt;
|
|
93
|
+
if (init?.initialMessages) this.messages = init.initialMessages.slice();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getMessages(): ChatMessage[] {
|
|
97
|
+
return this.messages.slice();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
setMessages(messages: ChatMessage[]): void {
|
|
101
|
+
this.messages = messages.slice();
|
|
102
|
+
this.emit({ type: 'messages_changed', messages: this.messages.slice() });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
subscribe(listener: (event: SessionEvent) => void): () => void {
|
|
106
|
+
this.listeners.add(listener);
|
|
107
|
+
return () => this.listeners.delete(listener);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private emit(event: SessionEvent): void {
|
|
111
|
+
for (const fn of this.listeners) {
|
|
112
|
+
try {
|
|
113
|
+
fn(event);
|
|
114
|
+
} catch {
|
|
115
|
+
// listeners must not break the session
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
appendSynthetic(msg: ChatMessage): void {
|
|
121
|
+
this.messages.push(msg);
|
|
122
|
+
this.emit({ type: 'messages_changed', messages: this.messages.slice() });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
queueForNextTurn(msg: ChatMessage): void {
|
|
126
|
+
this.queue.push(msg);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
abort(): void {
|
|
130
|
+
if (this.abortCtl) this.abortCtl.abort();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async submit(input: InboundMessage, _responder: ChannelResponder): Promise<void> {
|
|
134
|
+
if (input.text === undefined) return;
|
|
135
|
+
const userMsg: ChatMessage = { role: 'user', content: input.text };
|
|
136
|
+
await this.runTurn({ userMessage: userMsg });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async consumeAgentEvents(
|
|
140
|
+
cfg: ProviderConfig,
|
|
141
|
+
model: string,
|
|
142
|
+
registry: PluginRegistry,
|
|
143
|
+
signal: AbortSignal,
|
|
144
|
+
): Promise<ChatMessage[] | null> {
|
|
145
|
+
let final: ChatMessage[] | null = null;
|
|
146
|
+
let partialText = '';
|
|
147
|
+
let partialReasoning = '';
|
|
148
|
+
for await (const e of runAgent(this.messages, cfg, model, signal, registry)) {
|
|
149
|
+
if (e.type === 'content') {
|
|
150
|
+
partialText = e.text;
|
|
151
|
+
this.emit({ type: 'stream_partial', text: partialText, reasoning: partialReasoning });
|
|
152
|
+
} else if (e.type === 'reasoning') {
|
|
153
|
+
partialReasoning = e.text;
|
|
154
|
+
this.emit({ type: 'stream_partial', text: partialText, reasoning: partialReasoning });
|
|
155
|
+
} else if (e.type === 'messages') {
|
|
156
|
+
this.messages = e.messages.slice();
|
|
157
|
+
final = this.messages.slice();
|
|
158
|
+
this.emit({ type: 'messages_changed', messages: this.messages.slice() });
|
|
159
|
+
} else if (e.type === 'usage') {
|
|
160
|
+
this.emit({ type: 'usage', totalTokens: e.totalTokens, cachedTokens: e.cachedTokens ?? 0 });
|
|
161
|
+
} else if (e.type === 'turn_end') {
|
|
162
|
+
partialText = '';
|
|
163
|
+
partialReasoning = '';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return final;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async runTurn(options: RunTurnOptions): Promise<ChatMessage[] | null> {
|
|
170
|
+
// Re-entrance guard. Concurrent `runTurn` calls would overwrite
|
|
171
|
+
// `abortCtl` (orphaning the previous abort controller) and append two
|
|
172
|
+
// user messages onto the same transcript, racing the agent loop. Hosts
|
|
173
|
+
// are responsible for not interleaving turns; the SDK enforces it.
|
|
174
|
+
if (this.abortCtl !== null) {
|
|
175
|
+
throw new Error(`Session "${this.id}" already running a turn. Call abort() first or wait for completion.`);
|
|
176
|
+
}
|
|
177
|
+
if (options.baseMessages) this.messages = options.baseMessages.slice();
|
|
178
|
+
this.messages.push(options.userMessage);
|
|
179
|
+
if (this.queue.length) {
|
|
180
|
+
this.messages.push(...this.queue);
|
|
181
|
+
this.queue = [];
|
|
182
|
+
}
|
|
183
|
+
this.emit({ type: 'messages_changed', messages: this.messages.slice() });
|
|
184
|
+
this.emit({ type: 'stream_started' });
|
|
185
|
+
this.abortCtl = new AbortController();
|
|
186
|
+
const cfg: ProviderConfig = { ...(options.config ?? this.config) };
|
|
187
|
+
if (this.systemPrompt) cfg.systemPrompt = this.systemPrompt;
|
|
188
|
+
const model = options.model ?? this.model;
|
|
189
|
+
const registry = options.registry ?? this.registry;
|
|
190
|
+
try {
|
|
191
|
+
return await this.consumeAgentEvents(cfg, model, registry, this.abortCtl.signal);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
this.emit({ type: 'error', message: err instanceof Error ? err.message : String(err) });
|
|
194
|
+
return null;
|
|
195
|
+
} finally {
|
|
196
|
+
this.abortCtl = null;
|
|
197
|
+
this.emit({ type: 'stream_ended' });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function createSessionManager(opts: CreateSessionManagerOptions): SessionManager {
|
|
203
|
+
const sessions = new Map<string, SessionImpl>();
|
|
204
|
+
return {
|
|
205
|
+
getOrCreate(key, init) {
|
|
206
|
+
let s = sessions.get(key);
|
|
207
|
+
if (!s) {
|
|
208
|
+
s = new SessionImpl(key, opts.registry, opts.config, opts.model, init);
|
|
209
|
+
sessions.set(key, s);
|
|
210
|
+
}
|
|
211
|
+
return s;
|
|
212
|
+
},
|
|
213
|
+
get(key) {
|
|
214
|
+
return sessions.get(key);
|
|
215
|
+
},
|
|
216
|
+
list() {
|
|
217
|
+
return Array.from(sessions.values());
|
|
218
|
+
},
|
|
219
|
+
async close(key) {
|
|
220
|
+
const s = sessions.get(key);
|
|
221
|
+
if (s) {
|
|
222
|
+
s.abort();
|
|
223
|
+
sessions.delete(key);
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
package/src/types/llm.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export interface ProviderConfig {
|
|
2
|
+
baseUrl: string;
|
|
3
|
+
model?: string;
|
|
4
|
+
maxTokens: number;
|
|
5
|
+
temperature: number;
|
|
6
|
+
streamTimeoutMs: number;
|
|
7
|
+
systemPrompt?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Provider id to dispatch streaming through (`'openai'` by default).
|
|
10
|
+
* Resolved by the registry's `ProviderRegistry`. Lets a single host run
|
|
11
|
+
* multiple providers concurrently (e.g. coding via openai-compat,
|
|
12
|
+
* vision via a different API).
|
|
13
|
+
*/
|
|
14
|
+
providerId?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Usage {
|
|
18
|
+
promptTokens: number;
|
|
19
|
+
completionTokens: number;
|
|
20
|
+
totalTokens: number;
|
|
21
|
+
/** Subset of `promptTokens` that hit the prompt cache, when reported by the
|
|
22
|
+
* server (OpenAI: `usage.prompt_tokens_details.cached_tokens`; llama.cpp
|
|
23
|
+
* newer builds expose the same field). Undefined or 0 when unsupported. */
|
|
24
|
+
cachedPromptTokens?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ImageAttachment {
|
|
28
|
+
data: string; // base64 encoded
|
|
29
|
+
mimeType: string; // e.g. 'image/jpeg'
|
|
30
|
+
name: string; // filename for display
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ToolCall {
|
|
34
|
+
id: string;
|
|
35
|
+
function: {
|
|
36
|
+
name: string;
|
|
37
|
+
arguments: string;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ToolDefinition {
|
|
42
|
+
type: 'function';
|
|
43
|
+
function: {
|
|
44
|
+
name: string;
|
|
45
|
+
description: string;
|
|
46
|
+
parameters: Record<string, unknown>;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ToolResultInfo {
|
|
51
|
+
name: string;
|
|
52
|
+
content: string;
|
|
53
|
+
error?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Optional rendering hints carried alongside a ChatMessage. Lets plugins (and
|
|
58
|
+
* the host) tweak how a message is presented without committing to a fully
|
|
59
|
+
* custom renderer:
|
|
60
|
+
* - `color` overrides the role-default text/border color (e.g. agent color)
|
|
61
|
+
* - `prefix` is rendered inline before the content (e.g. `│ ` colored bar)
|
|
62
|
+
* - `badge` is shown in a small box before the body (e.g. agent name)
|
|
63
|
+
* - `hidden` keeps the message in the transcript (sent to the LLM) but skips
|
|
64
|
+
* its on-screen rendering — useful for system reminders.
|
|
65
|
+
*/
|
|
66
|
+
export interface MessageDisplay {
|
|
67
|
+
color?: string;
|
|
68
|
+
prefix?: string;
|
|
69
|
+
badge?: string;
|
|
70
|
+
hidden?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ChatMessage {
|
|
74
|
+
role: 'user' | 'assistant' | 'system' | 'tool';
|
|
75
|
+
content: string;
|
|
76
|
+
reasoning?: string;
|
|
77
|
+
images?: ImageAttachment[];
|
|
78
|
+
toolCalls?: ToolCall[];
|
|
79
|
+
toolCallId?: string;
|
|
80
|
+
toolResult?: ToolResultInfo;
|
|
81
|
+
toolCallArgs?: Record<string, string>;
|
|
82
|
+
/**
|
|
83
|
+
* Tag used by plugins to route this message to a custom renderer registered
|
|
84
|
+
* with `PluginContext.ui.registerMessageRenderer`. When set and the renderer
|
|
85
|
+
* is found, the renderer takes precedence over the role-default renderer.
|
|
86
|
+
*/
|
|
87
|
+
customType?: string;
|
|
88
|
+
/** Free-form bag for plugin-private state (e.g. agent name, sub-agent id). */
|
|
89
|
+
meta?: Record<string, unknown>;
|
|
90
|
+
/** Lightweight display tweaks; see `MessageDisplay`. */
|
|
91
|
+
display?: MessageDisplay;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type StreamChunk =
|
|
95
|
+
| { type: 'reasoning'; text: string }
|
|
96
|
+
| { type: 'content'; text: string }
|
|
97
|
+
| { type: 'tool_call'; toolCall: ToolCall };
|
|
98
|
+
|
|
99
|
+
export interface StreamOptions {
|
|
100
|
+
signal?: AbortSignal;
|
|
101
|
+
onUsage?: (usage: Usage) => void;
|
|
102
|
+
tools?: ToolDefinition[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ApiModel {
|
|
106
|
+
id: string;
|
|
107
|
+
}
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UIService is the host-supplied bridge that lets plugins prompt the user,
|
|
3
|
+
* surface notifications, and pin status text without coupling to a specific
|
|
4
|
+
* renderer (Ink, plain console, etc.). The TUI host (mu-coding) implements
|
|
5
|
+
* this with `InkUIService`; CLI / single-shot hosts can use `ConsoleUIService`.
|
|
6
|
+
*
|
|
7
|
+
* Plugins receive a `UIService` either directly through their own factory
|
|
8
|
+
* config or via `PluginContext.ui` when one is provided.
|
|
9
|
+
*/
|
|
10
|
+
export type UINotifyLevel = 'info' | 'success' | 'warning' | 'error';
|
|
11
|
+
|
|
12
|
+
export interface UIService {
|
|
13
|
+
notify: (message: string, level?: UINotifyLevel) => void;
|
|
14
|
+
confirm: (title: string, message: string) => Promise<boolean>;
|
|
15
|
+
select: (title: string, options: string[]) => Promise<string | null>;
|
|
16
|
+
input: (title: string, placeholder?: string) => Promise<string | null>;
|
|
17
|
+
setStatus: (key: string, text: string) => void;
|
|
18
|
+
clearStatus: (key: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Fallback UIService for non-interactive (single-shot) mode.
|
|
23
|
+
* Routes notifications to stderr; auto-resolves prompts in deterministic ways
|
|
24
|
+
* so non-interactive runs never block.
|
|
25
|
+
*/
|
|
26
|
+
export class ConsoleUIService implements UIService {
|
|
27
|
+
notify(message: string, level?: UINotifyLevel): void {
|
|
28
|
+
const prefix = level === 'error' ? '[ERROR]' : level === 'warning' ? '[WARN]' : '[INFO]';
|
|
29
|
+
console.error(`${prefix} ${message}`);
|
|
30
|
+
}
|
|
31
|
+
async confirm(_title: string, message: string): Promise<boolean> {
|
|
32
|
+
console.error(`[CONFIRM] ${message} (auto-accepting in non-interactive mode)`);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
async select(_title: string, options: string[]): Promise<string | null> {
|
|
36
|
+
console.error('[SELECT] Auto-selecting first option in non-interactive mode');
|
|
37
|
+
return options[0] ?? null;
|
|
38
|
+
}
|
|
39
|
+
async input(_title: string, _placeholder?: string): Promise<string | null> {
|
|
40
|
+
console.error('[INPUT] Cannot prompt in non-interactive mode');
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
setStatus(_key: string, _text: string): void {
|
|
44
|
+
/* no-op in console mode */
|
|
45
|
+
}
|
|
46
|
+
clearStatus(_key: string): void {
|
|
47
|
+
/* no-op in console mode */
|
|
48
|
+
}
|
|
49
|
+
}
|