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.
@@ -0,0 +1,11 @@
1
+ import type { AgentBaseConfig } from "../types";
2
+
3
+ export const BASE_CONFIG: AgentBaseConfig = {
4
+ access: {
5
+ allowAdmins: false,
6
+ users: [],
7
+ },
8
+ workspaceDir: "",
9
+ permissionLevel: "workspace-write",
10
+ model: "",
11
+ };
@@ -0,0 +1,15 @@
1
+ import type { AgentSettingsConfig } from "../types";
2
+
3
+ export const CONTEXT_WINDOW_CHOICES = [
4
+ { label: "128K", value: 128 * 1024 },
5
+ { label: "256K", value: 256 * 1024 },
6
+ { label: "512K", value: 512 * 1024 },
7
+ { label: "1M", value: 1024 * 1024 },
8
+ { label: "10M", value: 10 * 1024 * 1024 },
9
+ ];
10
+
11
+ export const DEFAULT_CONTEXT_WINDOW = 512 * 1024;
12
+
13
+ export function compactionThresholdTokens(maxContextTokens: number): number {
14
+ return Math.max(1, Math.floor(maxContextTokens * 0.9375));
15
+ }
@@ -0,0 +1,39 @@
1
+ import type { AgentSettingsConfig } from "../types";
2
+ import {
3
+ DEFAULT_CONTEXT_WINDOW,
4
+ compactionThresholdTokens,
5
+ } from "./context-window";
6
+
7
+ export const SETTINGS_CONFIG: AgentSettingsConfig = {
8
+ maxIterations: 500,
9
+ temperature: 1,
10
+ maxContextTokens: DEFAULT_CONTEXT_WINDOW,
11
+ stream: true,
12
+ enableMarkdownScreenshot: true,
13
+ compaction: {
14
+ enabled: true,
15
+ keepRecentMessages: 20,
16
+ },
17
+ webSearch: {
18
+ enabled: true,
19
+ baseUrl: "https://search.crystelf.top/",
20
+ timeoutMs: 8000,
21
+ defaultLimit: 5,
22
+ maxLimit: 8,
23
+ maxSearchCount: 50,
24
+ },
25
+ webFetch: {
26
+ enabled: true,
27
+ timeoutMs: 15_000,
28
+ maxChars: 12_000,
29
+ },
30
+ bash: {
31
+ enabled: true,
32
+ timeoutMs: 120_000,
33
+ approvalTimeoutMs: 5 * 60_000,
34
+ },
35
+ dataCollection: {
36
+ enabled: true,
37
+ },
38
+ debug: false,
39
+ };
@@ -0,0 +1,236 @@
1
+ import type {
2
+ ForwardSendNode,
3
+ ForwardSendOptions,
4
+ MessageSegment,
5
+ MiokuContext,
6
+ } from "mioku";
7
+ import type { AgentPermissionLevel } from "../types";
8
+
9
+ export type ActivityKind = "bash" | "write" | "edit";
10
+
11
+ export interface ActivityEntry {
12
+ kind: ActivityKind;
13
+ title: string;
14
+ purpose?: string;
15
+ note?: string;
16
+ output?: string;
17
+ ok: boolean;
18
+ durationMs: number;
19
+ }
20
+
21
+ export interface ActivityRecord {
22
+ kind: ActivityKind;
23
+ title: string;
24
+ purpose?: string;
25
+ note?: string;
26
+ output?: string;
27
+ ok?: boolean;
28
+ durationMs?: number;
29
+ }
30
+
31
+ export interface BashActivityResult {
32
+ exitCode: number | null;
33
+ timedOut: boolean;
34
+ error?: string;
35
+ stdout?: string;
36
+ stderr?: string;
37
+ }
38
+
39
+ const KIND_LABELS: Record<ActivityKind, string> = {
40
+ bash: "命令",
41
+ write: "写入",
42
+ edit: "编辑",
43
+ };
44
+
45
+ const OUTPUT_EXCERPT = 240;
46
+
47
+ function truncate(text: string, max: number): string {
48
+ const value = String(text ?? "").replace(/\s+/g, " ").trim();
49
+ return value.length > max ? `${value.slice(0, max)}…` : value;
50
+ }
51
+
52
+ function formatDuration(ms: number): string {
53
+ if (ms < 1000) return `${ms}ms`;
54
+ const seconds = ms / 1000;
55
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
56
+ const minutes = Math.floor(seconds / 60);
57
+ return `${minutes}m${Math.round(seconds % 60)}s`;
58
+ }
59
+
60
+ function formatClock(date: Date): string {
61
+ const pad = (value: number) => String(value).padStart(2, "0");
62
+ return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
63
+ }
64
+
65
+ export class TurnActivity {
66
+ private entries: ActivityEntry[] = [];
67
+ private startedAt = Date.now();
68
+ private subject = "";
69
+
70
+ constructor(private enabled: boolean) {}
71
+
72
+ setSubject(text: string): void {
73
+ this.subject = truncate(text, 24);
74
+ }
75
+
76
+ record(input: ActivityRecord): void {
77
+ if (!this.enabled) return;
78
+ this.entries.push({
79
+ kind: input.kind,
80
+ title: truncate(input.title, 200) || "(未命名)",
81
+ purpose: input.purpose ? truncate(input.purpose, 200) : undefined,
82
+ note: input.note ? truncate(input.note, 200) : undefined,
83
+ output: input.output,
84
+ ok: input.ok ?? true,
85
+ durationMs: input.durationMs ?? 0,
86
+ });
87
+ }
88
+
89
+ recordTool(
90
+ toolName: string,
91
+ args: Record<string, unknown>,
92
+ result: unknown,
93
+ durationMs: number,
94
+ ): void {
95
+ if (!this.enabled) return;
96
+ if (toolName !== "write" && toolName !== "edit") return;
97
+ const record = (result ?? {}) as Record<string, unknown>;
98
+ const detail = record.error
99
+ ? `错误:${truncate(String(record.error), 160)}`
100
+ : toolName === "write" && typeof record.bytes === "number"
101
+ ? `${record.bytes} 字节`
102
+ : toolName === "edit" && typeof record.replacements === "number"
103
+ ? `${record.replacements} 处替换`
104
+ : undefined;
105
+ this.record({
106
+ kind: toolName,
107
+ title: String(args?.file_path ?? ""),
108
+ note: detail,
109
+ ok: !record.error && record.success !== false,
110
+ durationMs,
111
+ });
112
+ }
113
+
114
+ recordBash(
115
+ notice: {
116
+ command: string;
117
+ level: AgentPermissionLevel;
118
+ purpose: string;
119
+ reason?: string;
120
+ },
121
+ result: BashActivityResult,
122
+ startedAt: number,
123
+ ): void {
124
+ this.record({
125
+ kind: "bash",
126
+ title: notice.command,
127
+ purpose: notice.purpose,
128
+ note: notice.reason,
129
+ output: bashOutput(result),
130
+ ok: result.exitCode === 0 && !result.timedOut && !result.error,
131
+ durationMs: Date.now() - startedAt,
132
+ });
133
+ }
134
+
135
+ get total(): number {
136
+ return this.entries.length;
137
+ }
138
+
139
+ get failureCount(): number {
140
+ return this.entries.filter((entry) => !entry.ok).length;
141
+ }
142
+
143
+ private stats(): string {
144
+ const counts = new Map<ActivityKind, number>();
145
+ for (const entry of this.entries) {
146
+ counts.set(entry.kind, (counts.get(entry.kind) ?? 0) + 1);
147
+ }
148
+ return [...counts.entries()]
149
+ .map(([kind, count]) => `${KIND_LABELS[kind]} ${count}`)
150
+ .join(" · ");
151
+ }
152
+
153
+ buildDisplay(): ForwardSendOptions {
154
+ const failures = this.failureCount;
155
+ return {
156
+ source: "Agent 执行记录",
157
+ news: [
158
+ { text: `${this.total} 项操作` },
159
+ ...(this.subject ? [{ text: this.subject }] : []),
160
+ ],
161
+ summary: [this.stats(), failures > 0 ? `失败 ${failures}` : "全部成功"]
162
+ .filter(Boolean)
163
+ .join(" · "),
164
+ };
165
+ }
166
+
167
+ buildNodes(
168
+ ctx: MiokuContext,
169
+ userId: string,
170
+ nickname: string,
171
+ ): ForwardSendNode[] {
172
+ const nodes: ForwardSendNode[] = [
173
+ {
174
+ user_id: userId,
175
+ nickname,
176
+ content: [ctx.segment.text(this.buildInfoText())],
177
+ },
178
+ ];
179
+ for (const [index, entry] of this.entries.entries()) {
180
+ nodes.push({
181
+ user_id: userId,
182
+ nickname,
183
+ content: [ctx.segment.text(buildEntryText(entry, index + 1))],
184
+ });
185
+ }
186
+ return nodes;
187
+ }
188
+
189
+ buildInfoText(): string {
190
+ const failures = this.failureCount;
191
+ return [
192
+ "【Agent 执行记录】",
193
+ `时间:${formatClock(new Date(this.startedAt))}`,
194
+ `操作:${this.total} 项(${this.stats()})`,
195
+ `结果:${failures > 0 ? `失败 ${failures} 项` : "全部成功"}`,
196
+ `耗时:${formatDuration(Date.now() - this.startedAt)}`,
197
+ `简介:${this.subject ? `针对「${this.subject}」` : "本次任务"}按时间顺序执行了以下操作。`,
198
+ ].join("\n");
199
+ }
200
+
201
+ buildFallbackSegments(ctx: MiokuContext): MessageSegment[] {
202
+ const display = this.buildDisplay();
203
+ const header = [
204
+ display.source,
205
+ display.news?.map((item) => item.text).join(" / "),
206
+ display.summary,
207
+ ]
208
+ .filter(Boolean)
209
+ .join("\n");
210
+ const segments: MessageSegment[] = [ctx.segment.text(header)];
211
+ for (const [index, entry] of this.entries.entries()) {
212
+ segments.push(ctx.segment.text(buildEntryText(entry, index + 1)));
213
+ }
214
+ return segments;
215
+ }
216
+ }
217
+
218
+ function buildEntryText(entry: ActivityEntry, index: number): string {
219
+ const lines = [
220
+ `[${index}] ${KIND_LABELS[entry.kind]} · ${entry.ok ? "成功" : "失败"} · ${formatDuration(entry.durationMs)}`,
221
+ entry.title,
222
+ ];
223
+ if (entry.purpose) lines.push(`用途:${entry.purpose}`);
224
+ if (entry.note) lines.push(`备注:${entry.note}`);
225
+ if (entry.output) lines.push(entry.output);
226
+ return lines.join("\n");
227
+ }
228
+
229
+ function bashOutput(result: BashActivityResult): string | undefined {
230
+ if (result.error) return `错误:${truncate(result.error, OUTPUT_EXCERPT)}`;
231
+ if (result.timedOut) return "超时:命令被强制结束";
232
+ if (result.exitCode === 0) return undefined;
233
+ const detail = `${result.stderr || ""}`.trim() || `${result.stdout || ""}`.trim();
234
+ if (!detail) return `退出码:${result.exitCode}`;
235
+ return `输出:${truncate(detail, OUTPUT_EXCERPT)}`;
236
+ }
@@ -0,0 +1,50 @@
1
+ import * as fsp from "node:fs/promises";
2
+ import type { Bot, MessageSegment, MessageTarget, MiokuContext } from "mioku";
3
+
4
+ export function isLocalFilePath(value: string): boolean {
5
+ return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
6
+ }
7
+
8
+ export function normalizeImageSource(file: string): string {
9
+ const value = String(file ?? "").trim();
10
+ if (!value) return value;
11
+ if (/^(?:file|base64|data|https?):/i.test(value)) return value;
12
+ if (isLocalFilePath(value)) return `file://${value}`;
13
+ return value;
14
+ }
15
+
16
+ export async function sendImageSource(
17
+ ctx: MiokuContext,
18
+ bot: Bot,
19
+ target: MessageTarget,
20
+ source: string,
21
+ prefix: MessageSegment[] = [],
22
+ ): Promise<boolean> {
23
+ const value = String(source ?? "").trim();
24
+ if (!value) return false;
25
+ try {
26
+ if (isLocalFilePath(value)) {
27
+ const buffer = await fsp.readFile(value);
28
+ await bot.sendMessage(target, [...prefix, ctx.segment.image(buffer)]);
29
+ return true;
30
+ }
31
+ await bot.sendMessage(target, [
32
+ ...prefix,
33
+ ctx.segment.image(normalizeImageSource(value)),
34
+ ]);
35
+ return true;
36
+ } catch (err) {
37
+ ctx.logger.warn(`[agent] failed to send image ${value}: ${err}`);
38
+ return false;
39
+ }
40
+ }
41
+
42
+ export async function sendLocalFile(
43
+ ctx: MiokuContext,
44
+ bot: Bot,
45
+ target: MessageTarget,
46
+ filePath: string,
47
+ name: string,
48
+ ): Promise<void> {
49
+ await bot.sendMessage(target, [ctx.segment.file(filePath, { name })]);
50
+ }
@@ -0,0 +1,24 @@
1
+ import type { ConfigService } from "mioku";
2
+ import type { ChatSharedConfig } from "../types";
3
+
4
+ export async function readChatSharedConfig(
5
+ configService: ConfigService | undefined,
6
+ ): Promise<ChatSharedConfig> {
7
+ if (!configService) return { persona: "", replyStyle: "", emotion: null };
8
+ const personalization = await configService
9
+ .getConfig("chat", "personalization")
10
+ .catch(() => null);
11
+ return {
12
+ persona: String(personalization?.persona ?? ""),
13
+ replyStyle: String(personalization?.replyStyle?.baseStyle ?? ""),
14
+ emotion:
15
+ personalization?.emotion && typeof personalization.emotion === "object"
16
+ ? {
17
+ defaultEmotion: String(
18
+ personalization.emotion.defaultEmotion ?? "default",
19
+ ),
20
+ emotions: personalization.emotion.emotions ?? {},
21
+ }
22
+ : null,
23
+ };
24
+ }
@@ -0,0 +1,97 @@
1
+ import type { AgentHost } from "../types";
2
+ import { compactionThresholdTokens } from "../configs/context-window";
3
+
4
+ export function estimateTokens(text: string): number {
5
+ const normalized = String(text ?? "").trim();
6
+ if (!normalized) return 0;
7
+ const cjkChars = normalized.match(/[\u3400-\u9fff\u3040-\u30ff]/g)?.length || 0;
8
+ const latinWords = normalized.match(/[A-Za-z0-9_]+/g)?.length || 0;
9
+ const symbols = Math.max(0, normalized.length - cjkChars);
10
+ return Math.max(1, Math.ceil(cjkChars * 0.6 + latinWords * 1.3 + symbols / 6));
11
+ }
12
+
13
+ export function estimateHistoryTokens(
14
+ messages: Array<{ content: string }>,
15
+ ): number {
16
+ return messages.reduce((sum, message) => sum + estimateTokens(message.content), 0);
17
+ }
18
+
19
+ function transcript(messages: Array<{ role: string; content: string }>): string {
20
+ return messages
21
+ .map((message) => {
22
+ const speaker =
23
+ message.role === "user" ? "USER" : message.role === "assistant" ? "AGENT" : "SYSTEM";
24
+ return `${speaker}: ${message.content}`;
25
+ })
26
+ .join("\n\n");
27
+ }
28
+
29
+ export interface CompactionResult {
30
+ compacted: boolean;
31
+ reason: string;
32
+ freedTokens: number;
33
+ }
34
+
35
+ export async function maybeCompact(
36
+ host: AgentHost,
37
+ userId: number,
38
+ options: { force?: boolean } = {},
39
+ ): Promise<CompactionResult> {
40
+ const settings = host.getSettings();
41
+ const compaction = settings.compaction;
42
+ if (!compaction.enabled && !options.force) {
43
+ return { compacted: false, reason: "compaction disabled", freedTokens: 0 };
44
+ }
45
+ const thresholdTokens = compactionThresholdTokens(settings.maxContextTokens);
46
+
47
+ const session = host.sessions.get(userId);
48
+ const messages = host.db.getMessagesAfter(session.sessionId, session.summaryUpTo);
49
+ if (messages.length <= compaction.keepRecentMessages) {
50
+ return {
51
+ compacted: false,
52
+ reason: `only ${messages.length} message(s), need more than ${compaction.keepRecentMessages}`,
53
+ freedTokens: 0,
54
+ };
55
+ }
56
+
57
+ const older = messages.slice(0, messages.length - compaction.keepRecentMessages);
58
+ const olderTokens = estimateHistoryTokens(older);
59
+ if (!options.force && olderTokens < thresholdTokens) {
60
+ return {
61
+ compacted: false,
62
+ reason: `~${olderTokens} tokens below threshold ${thresholdTokens}`,
63
+ freedTokens: 0,
64
+ };
65
+ }
66
+
67
+ const resolved = host.resolveModel();
68
+ const worker = resolved?.working ?? resolved?.instance;
69
+ if (!worker) {
70
+ return { compacted: false, reason: "no model available", freedTokens: 0 };
71
+ }
72
+
73
+ const previousSummary = session.summary
74
+ ? `Previous summary:\n${session.summary}\n\n`
75
+ : "";
76
+ const prompt = `${previousSummary}Conversation to summarize:\n\n${transcript(older)}\n\nWrite an updated running summary in English for an AI agent's long-term context. Keep: the user's requests and goals, key facts learned (files, paths, decisions, outcomes), open tasks and unresolved questions. Be factual and compact. Output only the summary text.`;
77
+
78
+ try {
79
+ const response = await worker.complete({
80
+ model: resolved?.workingModel || resolved?.model || "",
81
+ messages: [{ role: "user", content: prompt }],
82
+ temperature: 0.3,
83
+ });
84
+ const summary = response.content?.trim();
85
+ if (!summary) {
86
+ return { compacted: false, reason: "empty summary from model", freedTokens: 0 };
87
+ }
88
+ host.db.setSummary(session.sessionId, summary, older[older.length - 1].id);
89
+ host.logger.info(
90
+ `[agent] compacted ${older.length} messages for user ${userId} (freed ~${olderTokens} tokens)`,
91
+ );
92
+ return { compacted: true, reason: `${older.length} messages condensed`, freedTokens: olderTokens };
93
+ } catch (err) {
94
+ host.logger.warn(`[agent] compaction failed: ${err}`);
95
+ return { compacted: false, reason: `compaction failed: ${err}`, freedTokens: 0 };
96
+ }
97
+ }