mioku-plugin-agent 0.1.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/index.ts ADDED
@@ -0,0 +1,212 @@
1
+ import { definePlugin, getService, Services } from "mioku";
2
+ import type { AIInstance, AIModelRole, MiokuContext } from "mioku";
3
+ import { initDatabase } from "./db";
4
+ import { SessionManager } from "./core/session";
5
+ import { EmotionManager } from "./core/emotion";
6
+ import { ApprovalManager } from "./tools/approval";
7
+ import { readChatSharedConfig } from "./core/chat-config";
8
+ import { createMessageHandler } from "./handlers/message";
9
+ import { registerCommands } from "./commands";
10
+ import { mergeAgentConfig } from "./utils/config";
11
+ import { workspaceRootFor } from "./tools/perm";
12
+ import { BASE_CONFIG } from "./configs/base";
13
+ import { SETTINGS_CONFIG } from "./configs/settings";
14
+ import type {
15
+ AgentBaseConfig,
16
+ AgentHost,
17
+ AgentSettingsConfig,
18
+ ResolvedModel,
19
+ } from "./types";
20
+
21
+ function normalizeIdList(input: unknown): number[] {
22
+ if (!Array.isArray(input)) return [];
23
+ return Array.from(
24
+ new Set(
25
+ input
26
+ .map((item) => Math.floor(Number(item)))
27
+ .filter((id) => Number.isFinite(id) && id > 0),
28
+ ),
29
+ );
30
+ }
31
+
32
+ export default definePlugin({
33
+ name: "agent",
34
+ async setup(ctx: MiokuContext) {
35
+ const aiService = getService(ctx, Services.AI);
36
+ const configService = getService(ctx, Services.Config);
37
+ const screenshot = getService(ctx, Services.Screenshot);
38
+
39
+ if (!aiService) {
40
+ ctx.logger.error("AI 服务不可用");
41
+ return;
42
+ }
43
+
44
+ if (configService) {
45
+ await configService.registerConfig("agent", "base", BASE_CONFIG);
46
+ await configService.registerConfig("agent", "settings", SETTINGS_CONFIG);
47
+ }
48
+
49
+ let cachedBase: AgentBaseConfig = mergeAgentConfig(
50
+ BASE_CONFIG,
51
+ (await configService?.getConfig("agent", "base")) ?? {},
52
+ );
53
+ let cachedSettings: AgentSettingsConfig = mergeAgentConfig(
54
+ SETTINGS_CONFIG,
55
+ (await configService?.getConfig("agent", "settings")) ?? {},
56
+ );
57
+ if (!Array.isArray(cachedBase.access.users)) cachedBase.access.users = [];
58
+
59
+ const refreshBase = async () => {
60
+ cachedBase = mergeAgentConfig(
61
+ BASE_CONFIG,
62
+ (await configService?.getConfig("agent", "base")) ?? {},
63
+ );
64
+ if (!Array.isArray(cachedBase.access.users)) cachedBase.access.users = [];
65
+ };
66
+ const refreshSettings = async () => {
67
+ cachedSettings = mergeAgentConfig(
68
+ SETTINGS_CONFIG,
69
+ (await configService?.getConfig("agent", "settings")) ?? {},
70
+ );
71
+ };
72
+ if (configService) {
73
+ configService.onConfigChange("agent", "base", () =>
74
+ refreshBase().catch((err) =>
75
+ ctx.logger.error(`刷新 agent/base 缓存失败: ${err}`),
76
+ ),
77
+ );
78
+ configService.onConfigChange("agent", "settings", () =>
79
+ refreshSettings().catch((err) =>
80
+ ctx.logger.error(`刷新 agent/settings 缓存失败: ${err}`),
81
+ ),
82
+ );
83
+ }
84
+
85
+ const db = await initDatabase();
86
+ const sessions = new SessionManager(db);
87
+ const approvals = new ApprovalManager();
88
+ const emotions = new EmotionManager((userId, emotion) =>
89
+ sessions.setEmotion(userId, emotion),
90
+ );
91
+
92
+ const resolveModel = (): ResolvedModel | null => {
93
+ const getByRole = (role: AIModelRole) =>
94
+ aiService.getInstanceByRole?.(role) ?? aiService.get?.(role);
95
+ const main = getByRole("main") ?? aiService.getDefault?.();
96
+ if (!main) return null;
97
+ const working = getByRole("working") ?? main;
98
+ const vision = getByRole("vision") ?? working;
99
+ const bindings = aiService.getRoleBindings?.() ?? {
100
+ main: undefined,
101
+ working: undefined,
102
+ vision: undefined,
103
+ };
104
+ const models = aiService.listModels?.() ?? [];
105
+ const instanceName = (instance: AIInstance): string | undefined => {
106
+ const name = (instance as { name?: unknown }).name;
107
+ return typeof name === "string" ? name : undefined;
108
+ };
109
+ const pickModel = (full: string | undefined, instance: AIInstance) => {
110
+ if (full && full.includes("/")) {
111
+ return full.split("/").slice(1).join("/");
112
+ }
113
+ const name = instanceName(instance);
114
+ const info = aiService
115
+ .listInstances?.()
116
+ ?.find((item) => item.role === name || item.name === name);
117
+ return info?.modelId ?? "";
118
+ };
119
+
120
+ const overrideFullId = String(cachedBase.model ?? "").trim();
121
+ const overrideDesc = overrideFullId
122
+ ? models.find((item) => item.id === overrideFullId)
123
+ : undefined;
124
+ let instance = main;
125
+ let model = pickModel(bindings.main, main) || "";
126
+ if (overrideDesc) {
127
+ model = overrideDesc.modelId;
128
+ const info = aiService
129
+ .listInstances?.()
130
+ ?.find((item) => item.providerId === overrideDesc.providerId);
131
+ const candidate = info ? aiService.get?.(info.name) : undefined;
132
+ if (candidate) instance = candidate;
133
+ } else if (overrideFullId) {
134
+ model = overrideFullId.includes("/")
135
+ ? overrideFullId.split("/").slice(1).join("/")
136
+ : overrideFullId;
137
+ }
138
+
139
+ const workingModel = pickModel(bindings.working, working) || model;
140
+ const visionModel = pickModel(bindings.vision, vision) || workingModel;
141
+ const visionDesc =
142
+ models.find((item) => item.id === bindings.vision) ||
143
+ models.find((item) => item.modelId === visionModel);
144
+ const isMultimodal =
145
+ visionDesc?.capabilities?.includes("vision") ?? Boolean(visionModel);
146
+ const mainDesc =
147
+ overrideDesc ||
148
+ models.find((item) => item.id === bindings.main) ||
149
+ models.find((item) => item.modelId === model);
150
+ return {
151
+ instance,
152
+ model,
153
+ working,
154
+ workingModel,
155
+ vision,
156
+ visionModel,
157
+ isMultimodal,
158
+ contextWindow: mainDesc?.contextWindow ?? 0,
159
+ };
160
+ };
161
+
162
+ const host: AgentHost = {
163
+ ctx,
164
+ aiService,
165
+ configService,
166
+ screenshot,
167
+ db,
168
+ approvals,
169
+ sessions,
170
+ emotions,
171
+ getBase: () => cachedBase,
172
+ getSettings: () => cachedSettings,
173
+ getChatShared: () => readChatSharedConfig(configService),
174
+ resolveModel,
175
+ workspaceRoot: (userId: number) =>
176
+ workspaceRootFor(cachedBase.workspaceDir, userId),
177
+ isAllowed: async (userId: number) => {
178
+ const owners = (ctx.config.owners ?? []).map(Number);
179
+ if (owners.includes(userId)) return true;
180
+ if (
181
+ cachedBase.access.allowAdmins &&
182
+ (ctx.config.admins ?? []).map(Number).includes(userId)
183
+ ) {
184
+ return true;
185
+ }
186
+ return normalizeIdList(cachedBase.access.users).includes(userId);
187
+ },
188
+ updateBase: async (patch) => {
189
+ if (configService) {
190
+ await configService.updateConfig("agent", "base", patch);
191
+ return;
192
+ }
193
+ cachedBase = { ...cachedBase, ...patch };
194
+ },
195
+ logger: ctx.logger,
196
+ };
197
+
198
+ registerCommands(host);
199
+ ctx.handle("message", createMessageHandler(host));
200
+
201
+ const resolved = resolveModel();
202
+ ctx.logger.info(
203
+ `agent 插件加载成功 (model=${resolved ? resolved.model : "?"}, level=${cachedBase.permissionLevel})`,
204
+ );
205
+
206
+ return () => {
207
+ approvals.dispose();
208
+ db.close();
209
+ ctx.logger.info("agent 插件已卸载");
210
+ };
211
+ },
212
+ });
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "mioku-plugin-agent",
3
+ "version": "0.1.0",
4
+ "description": "超级用户 Agent 插件",
5
+ "main": "index.ts",
6
+ "type": "module",
7
+ "keywords": [
8
+ "mioku"
9
+ ],
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/mioku-lab/mioku-plugin-agent.git"
13
+ },
14
+ "mioku": {
15
+ "services": [
16
+ "ai",
17
+ "config",
18
+ "screenshot"
19
+ ]
20
+ },
21
+ "devDependencies": {
22
+ "@types/bun": "^1.3.14",
23
+ "mioku": "workspace:*"
24
+ },
25
+ "peerDependencies": {
26
+ "mioku": "^1.0.0"
27
+ }
28
+ }
@@ -0,0 +1,119 @@
1
+ import type { AgentPermissionLevel } from "../types";
2
+
3
+ export interface PendingApproval {
4
+ id: string;
5
+ userId: number;
6
+ command: string;
7
+ cwd: string;
8
+ level: AgentPermissionLevel;
9
+ purpose: string;
10
+ reason: string;
11
+ createdAt: number;
12
+ }
13
+
14
+ interface PendingEntry extends PendingApproval {
15
+ timer: ReturnType<typeof setTimeout>;
16
+ resolve: (approved: boolean) => void;
17
+ }
18
+
19
+ export class ApprovalManager {
20
+ private pending = new Map<string, PendingEntry>();
21
+ private latestByUser = new Map<number, string>();
22
+ private seq = 0;
23
+
24
+ create(
25
+ info: Omit<PendingApproval, "id" | "createdAt">,
26
+ timeoutMs: number,
27
+ ): { approval: PendingApproval; promise: Promise<boolean> } {
28
+ const id = `bash_${Date.now().toString(36)}_${(this.seq++).toString(36)}`;
29
+ const entry: PendingEntry = {
30
+ ...info,
31
+ id,
32
+ createdAt: Date.now(),
33
+ timer: setTimeout(() => this.resolve(id, false), Math.max(1, timeoutMs)),
34
+ resolve: () => {},
35
+ };
36
+ const promise = new Promise<boolean>((resolvePromise) => {
37
+ entry.resolve = resolvePromise;
38
+ });
39
+ this.pending.set(id, entry);
40
+ this.latestByUser.set(info.userId, id);
41
+ return { approval: entry, promise };
42
+ }
43
+
44
+ resolve(id: string, approved: boolean): boolean {
45
+ const entry = this.pending.get(id);
46
+ if (!entry) return false;
47
+ clearTimeout(entry.timer);
48
+ this.pending.delete(id);
49
+ if (this.latestByUser.get(entry.userId) === id) {
50
+ const next = this.latestFor(entry.userId);
51
+ if (next) this.latestByUser.set(entry.userId, next);
52
+ else this.latestByUser.delete(entry.userId);
53
+ }
54
+ entry.resolve(approved);
55
+ return true;
56
+ }
57
+
58
+ resolveLatest(userId: number, approved: boolean): PendingApproval | null {
59
+ const id = this.latestByUser.get(userId) ?? this.latestFor(userId);
60
+ if (!id) return null;
61
+ const entry = this.pending.get(id);
62
+ if (!entry) return null;
63
+ const approval = this.toPending(entry);
64
+ this.resolve(id, approved);
65
+ return approval;
66
+ }
67
+
68
+ latestByUserId(userId: number): PendingApproval | null {
69
+ const id = this.latestByUser.get(userId) ?? this.latestFor(userId);
70
+ const entry = id ? this.pending.get(id) : undefined;
71
+ return entry ? this.toPending(entry) : null;
72
+ }
73
+
74
+ cancelByUser(userId: number): number {
75
+ const ids = [...this.pending.values()]
76
+ .filter((entry) => entry.userId === userId)
77
+ .map((entry) => entry.id);
78
+ for (const id of ids) this.resolve(id, false);
79
+ return ids.length;
80
+ }
81
+
82
+ listByUser(userId: number): PendingApproval[] {
83
+ return [...this.pending.values()]
84
+ .filter((entry) => entry.userId === userId)
85
+ .sort((a, b) => a.createdAt - b.createdAt)
86
+ .map((entry) => this.toPending(entry));
87
+ }
88
+
89
+ dispose(): void {
90
+ for (const [id, entry] of this.pending) {
91
+ clearTimeout(entry.timer);
92
+ entry.resolve(false);
93
+ this.pending.delete(id);
94
+ }
95
+ this.latestByUser.clear();
96
+ }
97
+
98
+ private latestFor(userId: number): string | undefined {
99
+ let latest: PendingEntry | undefined;
100
+ for (const entry of this.pending.values()) {
101
+ if (entry.userId !== userId) continue;
102
+ if (!latest || entry.createdAt >= latest.createdAt) latest = entry;
103
+ }
104
+ return latest?.id;
105
+ }
106
+
107
+ private toPending(entry: PendingEntry): PendingApproval {
108
+ return {
109
+ id: entry.id,
110
+ userId: entry.userId,
111
+ command: entry.command,
112
+ cwd: entry.cwd,
113
+ level: entry.level,
114
+ purpose: entry.purpose,
115
+ reason: entry.reason,
116
+ createdAt: entry.createdAt,
117
+ };
118
+ }
119
+ }
package/tools/bash.ts ADDED
@@ -0,0 +1,280 @@
1
+ import * as fs from "node:fs";
2
+ import type { AITool } from "mioku";
3
+ import type { AgentPermissionLevel, BashConfig } from "../types";
4
+ import type { ApprovalManager } from "./approval";
5
+ import type { FsPolicy } from "./perm";
6
+ import { bashRequiresApproval } from "./perm";
7
+
8
+ const OUTPUT_MAX_CHARS = 20_000;
9
+ const MIN_TIMEOUT_MS = 1_000;
10
+
11
+ export interface BashApprovalNotice {
12
+ id: string;
13
+ command: string;
14
+ cwd: string;
15
+ level: AgentPermissionLevel;
16
+ purpose: string;
17
+ reason?: string;
18
+ }
19
+
20
+ export interface BashRunNotice {
21
+ command: string;
22
+ cwd: string;
23
+ level: AgentPermissionLevel;
24
+ purpose: string;
25
+ reason?: string;
26
+ }
27
+
28
+ export interface BashRunResult {
29
+ exitCode: number | null;
30
+ timedOut: boolean;
31
+ error?: string;
32
+ stdout?: string;
33
+ stderr?: string;
34
+ }
35
+
36
+ export interface BashReporter {
37
+ /** 需要用户审批时立即通知,任何模式都不延迟。 */
38
+ approval(notice: BashApprovalNotice): Promise<void>;
39
+ /** 即将自动执行:full 模式攒进操作流水,其余模式立即通知。 */
40
+ announce(notice: BashRunNotice): Promise<void>;
41
+ /** 执行结束:full 模式下写入本回合的操作流水,在最终回复前合并转发。 */
42
+ record(
43
+ notice: BashRunNotice,
44
+ result: BashRunResult,
45
+ startedAt: number,
46
+ ): void;
47
+ }
48
+
49
+ interface BashToolDeps {
50
+ userId: number;
51
+ policy: FsPolicy;
52
+ config: BashConfig;
53
+ approvals: ApprovalManager;
54
+ reporter: BashReporter;
55
+ assessRisk?: (
56
+ command: string,
57
+ purpose: string,
58
+ ) => Promise<{
59
+ dangerous: boolean;
60
+ reason: string;
61
+ }>;
62
+ }
63
+
64
+ interface ExecResult {
65
+ exitCode: number | null;
66
+ stdout: string;
67
+ stderr: string;
68
+ timedOut: boolean;
69
+ }
70
+
71
+ const UNIX_SHELL_CANDIDATES = [
72
+ "/bin/bash",
73
+ "/usr/bin/bash",
74
+ "/usr/local/bin/bash",
75
+ "/bin/sh",
76
+ ];
77
+
78
+ let unixShell: string | null = null;
79
+
80
+ function resolveUnixShell(): string {
81
+ if (unixShell) return unixShell;
82
+ const candidates = [process.env.SHELL, ...UNIX_SHELL_CANDIDATES];
83
+ unixShell =
84
+ candidates.find(
85
+ (candidate): candidate is string =>
86
+ typeof candidate === "string" &&
87
+ candidate.length > 0 &&
88
+ fs.existsSync(candidate),
89
+ ) ?? "sh";
90
+ return unixShell;
91
+ }
92
+
93
+ function shellInvocation(command: string): string[] {
94
+ if (process.platform === "win32") {
95
+ const comspec = process.env.ComSpec || process.env.COMSPEC || "cmd.exe";
96
+ return [comspec, "/d", "/s", "/c", command];
97
+ }
98
+ return [resolveUnixShell(), "-c", command];
99
+ }
100
+
101
+ function shellName(): string {
102
+ if (process.platform === "win32") return "cmd.exe";
103
+ return resolveUnixShell();
104
+ }
105
+
106
+ function shellHint(): string {
107
+ if (process.platform === "win32") {
108
+ return "Windows cmd.exe syntax (e.g. dir, type, copy). Do not use bash-only syntax.";
109
+ }
110
+ return "POSIX shell syntax (bash/sh).";
111
+ }
112
+
113
+ async function execShell(
114
+ command: string,
115
+ cwd: string,
116
+ timeoutMs: number,
117
+ ): Promise<ExecResult> {
118
+ fs.mkdirSync(cwd, { recursive: true });
119
+ const proc = Bun.spawn(shellInvocation(command), {
120
+ cwd,
121
+ stdout: "pipe",
122
+ stderr: "pipe",
123
+ stdin: "ignore",
124
+ });
125
+ let timedOut = false;
126
+ const timer = setTimeout(
127
+ () => {
128
+ timedOut = true;
129
+ proc.kill();
130
+ },
131
+ Math.max(MIN_TIMEOUT_MS, timeoutMs),
132
+ );
133
+ const [stdout, stderr] = await Promise.all([
134
+ new Response(proc.stdout).text(),
135
+ new Response(proc.stderr).text(),
136
+ ]);
137
+ const exitCode = await proc.exited;
138
+ clearTimeout(timer);
139
+ return {
140
+ exitCode,
141
+ stdout: stdout.slice(0, OUTPUT_MAX_CHARS),
142
+ stderr: stderr.slice(0, OUTPUT_MAX_CHARS),
143
+ timedOut,
144
+ };
145
+ }
146
+
147
+ export function createBashTool(deps: BashToolDeps): AITool {
148
+ const { userId, policy, config, approvals, reporter } = deps;
149
+ return {
150
+ name: "bash",
151
+ description:
152
+ `Execute a shell command with ${shellName()} (${shellHint()}). ` +
153
+ "Always pass `purpose`: a short human-readable reason for running this command, shown to the user. " +
154
+ (bashRequiresApproval(policy.level)
155
+ ? `Runs in ${policy.level} mode: each command requires explicit user approval in the chat before execution. Working directory is the workspace (${policy.workspaceRoot}).`
156
+ : policy.level === "auto"
157
+ ? `Runs in auto mode: commands execute without asking first, but every command is reviewed by the working model and destructive ones still require the user's approval. Working directory is the workspace (${policy.workspaceRoot}).`
158
+ : policy.level === "yolo"
159
+ ? `Runs in yolo mode without approval and without notifying the user. Working directory defaults to the workspace (${policy.workspaceRoot}).`
160
+ : `Runs in full mode without approval; every command and its purpose is collected and reported to the user as one merged record after the turn. Working directory defaults to the workspace (${policy.workspaceRoot}).`),
161
+ parameters: {
162
+ type: "object",
163
+ properties: {
164
+ command: {
165
+ type: "string",
166
+ description: `The shell command to execute (${shellName()}, ${shellHint()})`,
167
+ },
168
+ purpose: {
169
+ type: "string",
170
+ description:
171
+ "Short reason for this command (what it does and why), shown to the user for approval/notification",
172
+ },
173
+ timeout_ms: {
174
+ type: "number",
175
+ description: "Timeout in milliseconds (default from config)",
176
+ },
177
+ },
178
+ required: ["command", "purpose"],
179
+ },
180
+ handler: async (args: Record<string, unknown>) => {
181
+ const command = String(args?.command ?? "").trim();
182
+ if (!command) return { error: "command must be a non-empty string" };
183
+ const purpose = String(args?.purpose ?? "").trim();
184
+ if (!purpose) {
185
+ return {
186
+ error:
187
+ "purpose must be a non-empty string: describe what this command does and why it is needed",
188
+ };
189
+ }
190
+ if (!config.enabled) {
191
+ return { error: "bash execution is disabled in agent settings" };
192
+ }
193
+
194
+ const verdict = bashRequiresApproval(policy.level)
195
+ ? {
196
+ dangerous: true,
197
+ reason: `${policy.level} 模式下每条命令都需要审批`,
198
+ }
199
+ : deps.assessRisk
200
+ ? await deps.assessRisk(command, purpose)
201
+ : { dangerous: false, reason: "" };
202
+
203
+ const notice: BashRunNotice = {
204
+ command,
205
+ cwd: policy.workspaceRoot,
206
+ level: policy.level,
207
+ purpose,
208
+ reason: verdict.reason,
209
+ };
210
+
211
+ if (verdict.dangerous) {
212
+ const { approval, promise } = approvals.create(
213
+ {
214
+ userId,
215
+ command,
216
+ cwd: policy.workspaceRoot,
217
+ level: policy.level,
218
+ purpose,
219
+ reason: verdict.reason,
220
+ },
221
+ config.approvalTimeoutMs,
222
+ );
223
+ await reporter
224
+ .approval({
225
+ id: approval.id,
226
+ command: approval.command,
227
+ cwd: approval.cwd,
228
+ level: approval.level,
229
+ purpose: approval.purpose,
230
+ reason: approval.reason,
231
+ })
232
+ .catch(() => {});
233
+ const approved = await promise;
234
+ if (!approved) {
235
+ return {
236
+ success: false,
237
+ error:
238
+ "The user denied the command (or the approval request timed out). Do not retry the same command; adjust the plan or ask the user.",
239
+ };
240
+ }
241
+ } else {
242
+ await reporter.announce(notice).catch(() => {});
243
+ }
244
+
245
+ const timeoutMs = Math.max(
246
+ MIN_TIMEOUT_MS,
247
+ Math.floor(Number(args?.timeout_ms) || config.timeoutMs),
248
+ );
249
+ const startedAt = Date.now();
250
+ try {
251
+ const result = await execShell(
252
+ command,
253
+ policy.workspaceRoot,
254
+ timeoutMs,
255
+ );
256
+ reporter.record(notice, result, startedAt);
257
+ if (result.timedOut) {
258
+ return {
259
+ success: false,
260
+ error: `Command timed out after ${timeoutMs}ms and was killed.`,
261
+ stdout: result.stdout,
262
+ stderr: result.stderr,
263
+ };
264
+ }
265
+ return {
266
+ exitCode: result.exitCode,
267
+ stdout: result.stdout,
268
+ stderr: result.stderr,
269
+ };
270
+ } catch (err) {
271
+ reporter.record(
272
+ notice,
273
+ { exitCode: null, timedOut: false, error: String(err) },
274
+ startedAt,
275
+ );
276
+ return { error: `Failed to execute command: ${err}` };
277
+ }
278
+ },
279
+ };
280
+ }