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/core/media.ts ADDED
@@ -0,0 +1,170 @@
1
+ import type { Message, MessageEvent, MultimodalContentItem } from "mioku";
2
+ import type { AgentHost } from "../types";
3
+ import { readImageDataUrl, toFileUrl, type DownloadedMedia } from "./download";
4
+
5
+ export type MediaKind = "image" | "file" | "video" | "record";
6
+
7
+ export interface MediaAttachment {
8
+ kind: MediaKind;
9
+ messageId: string;
10
+ name?: string;
11
+ size?: number;
12
+ url?: string;
13
+ file?: string;
14
+ path?: string;
15
+ fileId?: string;
16
+ userId?: string;
17
+ groupId?: string;
18
+ }
19
+
20
+ const MEDIA_KINDS: readonly string[] = ["image", "file", "video", "record"];
21
+
22
+ function pickString(value: unknown): string | undefined {
23
+ if (typeof value !== "string") return undefined;
24
+ const trimmed = value.trim();
25
+ return trimmed ? trimmed : undefined;
26
+ }
27
+
28
+ function pickNumber(value: unknown): number | undefined {
29
+ const parsed = Number(value);
30
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
31
+ }
32
+
33
+ export function extractMediaFromMessage(
34
+ message: Message | undefined,
35
+ messageId: string,
36
+ context: { userId?: string; groupId?: string } = {},
37
+ ): MediaAttachment[] {
38
+ const items: MediaAttachment[] = [];
39
+ for (const segment of message ?? []) {
40
+ if (!MEDIA_KINDS.includes(segment.type)) continue;
41
+ const data = segment.data ?? {};
42
+ const attachment = segment.attachment;
43
+ items.push({
44
+ kind: segment.type as MediaKind,
45
+ messageId,
46
+ userId: context.userId,
47
+ groupId: context.groupId,
48
+ name:
49
+ pickString(data.name) ??
50
+ pickString(data.file_name) ??
51
+ pickString(data.file_unique) ??
52
+ pickString(attachment?.name),
53
+ size:
54
+ pickNumber(data.size) ?? pickNumber(data.file_size) ?? attachment?.size,
55
+ url: pickString(data.url) ?? pickString(attachment?.url),
56
+ file: pickString(data.file) ?? pickString(attachment?.file),
57
+ path: pickString(data.path),
58
+ fileId:
59
+ pickString(data.file_id) ??
60
+ pickString(data.fid) ??
61
+ pickString(data.fileId),
62
+ });
63
+ }
64
+ return items;
65
+ }
66
+
67
+ export function extractMedia(event: MessageEvent): MediaAttachment[] {
68
+ return extractMediaFromMessage(
69
+ event.message,
70
+ String(event.message_id ?? ""),
71
+ {
72
+ userId: event.user_id ? String(event.user_id) : undefined,
73
+ groupId: event.group_id ? String(event.group_id) : undefined,
74
+ },
75
+ );
76
+ }
77
+
78
+ export function formatMediaNote(
79
+ downloads: DownloadedMedia[],
80
+ errors: string[] = [],
81
+ ): string {
82
+ if (downloads.length === 0 && errors.length === 0) return "";
83
+ const lines = downloads.map(
84
+ (item, index) =>
85
+ `- [${index + 1}] message_id=${item.messageId || "unknown"} name=${item.name} [${toFileUrl(item.path)}]`,
86
+ );
87
+ lines.push(...errors.map((error) => `- download failed: ${error}`));
88
+ return [
89
+ "[Attached files] all saved on disk (images are also attached to this message):",
90
+ ...lines,
91
+ ].join("\n");
92
+ }
93
+
94
+ export async function describeImageFile(
95
+ host: AgentHost,
96
+ filePath: string,
97
+ ): Promise<string> {
98
+ const resolved = host.resolveModel();
99
+ const vision = resolved?.vision ?? resolved?.instance;
100
+ const visionModel = resolved?.visionModel || resolved?.model || "";
101
+ if (!vision) return "(vision model unavailable)";
102
+
103
+ let dataUrl: string;
104
+ try {
105
+ dataUrl = await readImageDataUrl(filePath);
106
+ } catch (err) {
107
+ return `(failed to read image: ${err})`;
108
+ }
109
+
110
+ const content: MultimodalContentItem[] = [
111
+ {
112
+ type: "text",
113
+ text: "Describe this image in detail, including any visible text, UI elements and data.",
114
+ },
115
+ { type: "image_url", image_url: { url: dataUrl, detail: "auto" } },
116
+ ];
117
+ const response = await vision.complete({
118
+ model: visionModel,
119
+ messages: [
120
+ {
121
+ role: "system",
122
+ content:
123
+ "You are an image analysis assistant. Describe the image clearly and objectively in 2-6 sentences. Transcribe any visible text accurately.",
124
+ },
125
+ { role: "user", content },
126
+ ],
127
+ temperature: 0.3,
128
+ });
129
+ return response.content?.trim() || "(no description returned)";
130
+ }
131
+
132
+ export async function describeImageUrls(
133
+ host: AgentHost,
134
+ urls: string[],
135
+ ): Promise<string> {
136
+ const resolved = host.resolveModel();
137
+ const vision = resolved?.vision ?? resolved?.instance;
138
+ const visionModel = resolved?.visionModel || resolved?.model || "";
139
+ if (!vision || urls.length === 0) return "";
140
+ const content: MultimodalContentItem[] = [
141
+ {
142
+ type: "text",
143
+ text: "Describe the attached image(s) from a chat message in 2-4 sentences. Transcribe any visible text.",
144
+ },
145
+ ...urls.map(
146
+ (url): MultimodalContentItem => ({
147
+ type: "image_url",
148
+ image_url: { url, detail: "auto" },
149
+ }),
150
+ ),
151
+ ];
152
+ try {
153
+ const response = await vision.complete({
154
+ model: visionModel,
155
+ messages: [
156
+ {
157
+ role: "system",
158
+ content:
159
+ "You are an image analysis assistant. Describe the image clearly and objectively.",
160
+ },
161
+ { role: "user", content },
162
+ ],
163
+ temperature: 0.3,
164
+ });
165
+ return response.content?.trim() || "";
166
+ } catch (err) {
167
+ host.logger.warn(`[agent] image describe failed: ${err}`);
168
+ return "";
169
+ }
170
+ }
package/core/prompt.ts ADDED
@@ -0,0 +1,214 @@
1
+ import type {
2
+ AgentBaseConfig,
3
+ AgentSettingsConfig,
4
+ ChatEmotionConfig,
5
+ } from "../types";
6
+ import type { FsPolicy } from "../tools/perm";
7
+
8
+ const LEVEL_LINES: Record<string, string> = {
9
+ "read-only":
10
+ "Permission level: read-only. File writes are impossible; every bash command requires the user's explicit approval in the chat.",
11
+ "workspace-write":
12
+ "Permission level: workspace-write. File tools may only write inside the workspace; every bash command requires the user's explicit approval in the chat.",
13
+ auto: "Permission level: auto. File tools and bash run without asking first, but every bash command is reviewed by the working model and genuinely destructive ones still require the user's approval in the chat.",
14
+ full: "Permission level: full access. File tools and bash commands run without approval and are NOT sandboxed — be careful with destructive operations.",
15
+ yolo: "Permission level: yolo. Everything runs unsandboxed and without approval, and the user is NOT shown any intermediate notices — only your final reply reaches them. Be extra careful with destructive operations.",
16
+ };
17
+
18
+ function currentTimeLine(): string {
19
+ const now = new Date();
20
+ const pad = (value: number) => String(value).padStart(2, "0");
21
+ const days = [
22
+ "Sunday",
23
+ "Monday",
24
+ "Tuesday",
25
+ "Wednesday",
26
+ "Thursday",
27
+ "Friday",
28
+ "Saturday",
29
+ ];
30
+ return `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())} ${pad(now.getHours())}:00 (${days[now.getDay()]}, hour precision)`;
31
+ }
32
+
33
+ function buildEmotionSection(
34
+ emotion: ChatEmotionConfig | null,
35
+ currentEmotion: string,
36
+ ): string {
37
+ if (!emotion) return "";
38
+ const available = Array.from(
39
+ new Set([
40
+ "default",
41
+ ...Object.keys(emotion.emotions ?? {}).map((name) =>
42
+ name.trim().toLowerCase(),
43
+ ),
44
+ ]),
45
+ ).filter(Boolean);
46
+ const defaultEmotion = emotion.defaultEmotion || "default";
47
+ const current = available.includes(currentEmotion)
48
+ ? currentEmotion
49
+ : defaultEmotion;
50
+ const currentExamples = emotion.emotions?.[current]?.examples ?? [];
51
+ const fallbackExamples = emotion.emotions?.[defaultEmotion]?.examples ?? [];
52
+ const examples = (
53
+ currentExamples.length > 0 ? currentExamples : fallbackExamples
54
+ ).slice(0, 6);
55
+ const lines = [
56
+ "## Emotion State",
57
+ `Current emotion: ${current || "default"}`,
58
+ available.length > 0 ? `Available emotions: ${available.join(", ")}` : "",
59
+ "You may switch your emotion state by writing [emotion:name] on its own line; the marker is removed before the message is sent. Use it sparingly, when the emotion genuinely shifts. Nothing else changes it, so it stays until you switch it.",
60
+ ];
61
+ if (examples.length > 0) {
62
+ lines.push(
63
+ "For examples of responses to the current emotion, refer to their tone and speech characteristics.",
64
+ "Imitate their tone and speaking style, including sentence length, pauses inside sentences, and punctuation use:",
65
+ ...examples.map((example) => `- ${example}`),
66
+ );
67
+ }
68
+ return lines.filter(Boolean).join("\n");
69
+ }
70
+
71
+ export interface SystemPromptOptions {
72
+ persona: string;
73
+ replyStyle: string;
74
+ base: AgentBaseConfig;
75
+ settings: AgentSettingsConfig;
76
+ policy: FsPolicy;
77
+ currentEmotion: string;
78
+ emotion: ChatEmotionConfig | null;
79
+ toolNames: string[];
80
+ goal?: string;
81
+ plan?: Array<{ content: string; status: string }>;
82
+ }
83
+
84
+ export function buildSystemPrompt(options: SystemPromptOptions): string {
85
+ const {
86
+ persona,
87
+ replyStyle,
88
+ base,
89
+ settings,
90
+ policy,
91
+ currentEmotion,
92
+ emotion,
93
+ toolNames,
94
+ goal,
95
+ plan,
96
+ } = options;
97
+ const sections: string[] = [];
98
+
99
+ // Persona comes from the chat plugin's personalization config. When chat is
100
+ // absent there is no persona section at all, only the agent framing.
101
+ const personaText = persona.trim();
102
+ sections.push(
103
+ [
104
+ "## Identity",
105
+ ...(personaText ? [personaText, ""] : []),
106
+ "You are running as a personal agent: the user talks to you in a private chat and you complete tasks end-to-end with tools, like a coding/OS agent but conversational.",
107
+ ].join("\n"),
108
+ );
109
+
110
+ const styleText = replyStyle.trim();
111
+ if (styleText) {
112
+ sections.push(
113
+ [
114
+ "## Speaking Style",
115
+ "Long-term stable tone, read from the chat plugin's personalization settings. Keep it across every reply:",
116
+ styleText,
117
+ ].join("\n"),
118
+ );
119
+ }
120
+
121
+ sections.push(
122
+ [
123
+ "## Output Rules",
124
+ "- Your final text reply is delivered to the user. Output only the reply itself; never output your thinking process or narrate tool calls.",
125
+ "- Plain text output must NOT contain Markdown syntax.",
126
+ "- When the reply genuinely needs rich structure (code, tables, tutorials, long technical explanations), wrap that part in exactly <MARKDOWN> ... </MARKDOWN>. The block is rendered into an image; inside it there is no length limit and Markdown syntax is expected.",
127
+ "- Reach for a <MARKDOWN> block whenever the answer is table-like or list-like, or whenever you catch yourself laying out three or more parallel items with newlines, dashes or numbering; use it for two or more parallel items when each one carries more than a few words. It is also the right place for any code, path list, or long technical write-up.",
128
+ "- Put a <MARKDOWN> block on its own paragraph, with nothing else on the same line as the opening or closing tag. You may send plain-text lines before and after it, and more than one <MARKDOWN> block per reply is fine when it reads better.",
129
+ "- [emotion:name] on its own line switches your emotion state (see Emotion State). No other markers exist; do not invent any.",
130
+ "- To quote a chat message, put [reply:message_id] alone on the first line of your reply. The marker is removed and the message that follows quotes that message; use it when the user should see which message you are answering.",
131
+ ].join("\n"),
132
+ );
133
+
134
+ const toolLines: string[] = [
135
+ "## Tools",
136
+ "- Use tools proactively to complete tasks and verify facts instead of guessing. Chain multiple tool calls when needed.",
137
+ `Available tools: ${toolNames.join(", ")}.`,
138
+ "- File paths may be absolute or relative to the workspace. Relative paths are preferred.",
139
+ ];
140
+ if (settings.bash.enabled) {
141
+ toolLines.push(
142
+ policy.level === "full"
143
+ ? "- bash runs unsandboxed with full access; avoid destructive commands unless the user explicitly asked for them."
144
+ : policy.level === "auto"
145
+ ? "- bash runs without asking first, but the working model reviews every command and destructive ones still need the user's approval."
146
+ : "- bash requires in-chat user approval per command; batch related work into a single well-formed command instead of many tiny ones, and continue the task once approval is granted.",
147
+ "- Always pass `purpose` to bash: one short line saying what the command does and why; it is shown to the user with the command.",
148
+ );
149
+ }
150
+ if (settings.webSearch.enabled) {
151
+ toolLines.push(
152
+ `- web_search is limited to about ${settings.webSearch.maxSearchCount} searches per conversation; stop searching and answer from what you have after 2-3 failed attempts.`,
153
+ );
154
+ }
155
+ if (settings.webFetch.enabled && settings.webSearch.enabled) {
156
+ toolLines.push(
157
+ "- web_fetch reads a known URL directly; use web_search first when you need to discover sources.",
158
+ );
159
+ }
160
+ toolLines.push(
161
+ "- send_file/send_image deliver local files to the user; mention what you sent in one short line.",
162
+ );
163
+ toolLines.push(
164
+ "- view_image opens a local image file (screenshot, downloaded photo) so you can see it; use it whenever the answer depends on what an image actually shows.",
165
+ );
166
+ if (base.permissionLevel === "yolo") {
167
+ toolLines.push(
168
+ "- This mode hides every command/tool notice from the user: only your final reply is delivered, so make it complete, self-contained and free of tool narration.",
169
+ );
170
+ }
171
+ sections.push(toolLines.join("\n"));
172
+
173
+ sections.push(
174
+ [
175
+ "## Environment",
176
+ `Current time: ${currentTimeLine()}`,
177
+ "Chat type: private chat with the user.",
178
+ `Workspace: ${policy.workspaceRoot}`,
179
+ LEVEL_LINES[base.permissionLevel] ?? LEVEL_LINES["workspace-write"],
180
+ ].join("\n"),
181
+ );
182
+
183
+ const emotionSection = buildEmotionSection(emotion, currentEmotion);
184
+ if (emotionSection) sections.push(emotionSection);
185
+
186
+ if (goal?.trim()) {
187
+ sections.push(
188
+ [
189
+ "## Session Goal",
190
+ goal.trim(),
191
+ "This goal was set by the user for the current session. Work toward it across turns; it stays until the user clears or replaces it.",
192
+ ].join("\n"),
193
+ );
194
+ }
195
+ if (plan && plan.length > 0) {
196
+ const marker: Record<string, string> = {
197
+ pending: "[ ]",
198
+ in_progress: "[~]",
199
+ completed: "[x]",
200
+ };
201
+ sections.push(
202
+ [
203
+ "## Current Plan",
204
+ ...plan.map(
205
+ (item, index) =>
206
+ `${index + 1}. ${marker[item.status] ?? "[ ]"} ${item.content}`,
207
+ ),
208
+ "Keep this plan up to date with the todo_write tool as you make progress: mark items in_progress before starting and completed when done.",
209
+ ].join("\n"),
210
+ );
211
+ }
212
+
213
+ return sections.join("\n\n");
214
+ }
package/core/risk.ts ADDED
@@ -0,0 +1,61 @@
1
+ import type { AgentHost } from "../types";
2
+ import { extractJsonObject } from "../utils/json";
3
+
4
+ export interface RiskVerdict {
5
+ dangerous: boolean;
6
+ reason: string;
7
+ }
8
+
9
+ const REVIEWER_PROMPT = `You are a safety reviewer for shell commands an AI agent wants to run on the user's machine.
10
+
11
+ dangerous=true (the user must approve first) for anything irreversible, destructive or outside the workspace: deleting/moving/overwriting files or directories (rm, rmdir, mv, shred, truncate, mass globs), dropping or truncating databases, wiping data/cache, git push/force push/reset --hard/clean -fd, killing processes, restarting services, changing system or network config, installing/removing system packages, sudo.
12
+
13
+ dangerous=false for ordinary development work: read-only inspection (ls, cat, head, grep, find, ps, df, git status/diff/log), reading or creating files, writing inside the workspace, running tests/builds/linters, starting local dev servers.
14
+
15
+ Answer with JSON only: {"dangerous": true|false, "reason": "one short sentence"}`;
16
+
17
+ export async function assessCommandRisk(
18
+ host: AgentHost,
19
+ command: string,
20
+ purpose: string,
21
+ ): Promise<RiskVerdict> {
22
+ const resolved = host.resolveModel();
23
+ const reviewer = resolved?.working ?? resolved?.instance;
24
+ const model = resolved?.workingModel || resolved?.model || "";
25
+ if (!reviewer) {
26
+ return { dangerous: true, reason: "no reviewer model available" };
27
+ }
28
+
29
+ try {
30
+ const response = await reviewer.complete({
31
+ model,
32
+ messages: [
33
+ { role: "system", content: REVIEWER_PROMPT },
34
+ {
35
+ role: "user",
36
+ content: `Command:\n${command}\n\nStated purpose: ${purpose || "(none)"}`,
37
+ },
38
+ ],
39
+ temperature: 0,
40
+ max_tokens: 200,
41
+ });
42
+ const parsed = extractJsonObject<{ dangerous?: unknown; reason?: unknown }>(
43
+ response.content || "",
44
+ );
45
+ if (!parsed || typeof parsed.dangerous !== "boolean") {
46
+ return {
47
+ dangerous: true,
48
+ reason: "reviewer returned an unusable verdict",
49
+ };
50
+ }
51
+ return {
52
+ dangerous: parsed.dangerous,
53
+ reason:
54
+ String(parsed.reason ?? "").trim() ||
55
+ (parsed.dangerous ? "flagged by the reviewer model" : ""),
56
+ };
57
+ } catch (err) {
58
+ host.logger.warn(`[agent] command risk review failed: ${err}`);
59
+ return { dangerous: true, reason: `review failed: ${err}` };
60
+ }
61
+ }
package/core/send.ts ADDED
@@ -0,0 +1,131 @@
1
+ import type { Bot, MessageSegment } from "mioku";
2
+ import type { AgentHost } from "../types";
3
+ import { sendImageSource } from "./attachment";
4
+ import {
5
+ cleanEmotionMarkers,
6
+ consumeCompleteStreamUnits,
7
+ createThinkTagStreamFilter,
8
+ extractReplyMarker,
9
+ extractStandaloneMarkdownBlock,
10
+ splitOutgoingUnits,
11
+ } from "./units";
12
+
13
+ const MAX_MESSAGE_CHARS = 1200;
14
+
15
+ function splitLongText(text: string): string[] {
16
+ if (text.length <= MAX_MESSAGE_CHARS) return [text];
17
+ const chunks: string[] = [];
18
+ let rest = text;
19
+ while (rest.length > MAX_MESSAGE_CHARS) {
20
+ let cut = rest.lastIndexOf("\n", MAX_MESSAGE_CHARS);
21
+ if (cut < MAX_MESSAGE_CHARS * 0.5) cut = MAX_MESSAGE_CHARS;
22
+ chunks.push(rest.slice(0, cut).trim());
23
+ rest = rest.slice(cut).trim();
24
+ }
25
+ if (rest) chunks.push(rest);
26
+ return chunks.filter(Boolean);
27
+ }
28
+
29
+ export class TurnSender {
30
+ private streamBuffer = "";
31
+ private thinkFilter = createThinkTagStreamFilter();
32
+ private quoteId: string | null = null;
33
+ streamedText = "";
34
+ sentCount = 0;
35
+
36
+ constructor(
37
+ private host: AgentHost,
38
+ private bot: Bot | undefined,
39
+ private userId: number,
40
+ private enableScreenshot: boolean,
41
+ ) {}
42
+
43
+ /** 当前待用的引用段:只有本轮的**第一条**消息带引用,发送成功后才消费掉。 */
44
+ private quoteSegments(): MessageSegment[] {
45
+ return this.quoteId ? [this.host.ctx.segment.reply(this.quoteId)] : [];
46
+ }
47
+
48
+ private async deliverText(text: string): Promise<void> {
49
+ const trimmed = text.trim();
50
+ if (!trimmed || trimmed === "---") return;
51
+ if (!this.bot) return;
52
+ const chunks = splitLongText(trimmed);
53
+ for (const [index, chunk] of chunks.entries()) {
54
+ const segments = index === 0 ? this.quoteSegments() : [];
55
+ segments.push(this.host.ctx.segment.text(chunk));
56
+ await this.bot.sendMessage(
57
+ { type: "private", user_id: this.userId },
58
+ segments,
59
+ );
60
+ if (index === 0) this.quoteId = null;
61
+ this.sentCount += 1;
62
+ }
63
+ }
64
+
65
+ private async deliverUnit(unit: string): Promise<void> {
66
+ const cleaned = cleanEmotionMarkers(unit);
67
+ const { text, replyTo } = extractReplyMarker(cleaned.text);
68
+ if (replyTo) this.quoteId = replyTo;
69
+ if (!text) return;
70
+ this.streamedText = this.streamedText
71
+ ? `${this.streamedText}\n${text}`
72
+ : text;
73
+ const markdown = extractStandaloneMarkdownBlock(text);
74
+ if (markdown && this.enableScreenshot && this.host.screenshot && this.bot) {
75
+ try {
76
+ const imagePath = await this.host.screenshot.screenshotMarkdown(markdown);
77
+ if (imagePath) {
78
+ const sent = await sendImageSource(
79
+ this.host.ctx,
80
+ this.bot,
81
+ { type: "private", user_id: this.userId },
82
+ imagePath,
83
+ this.quoteSegments(),
84
+ );
85
+ if (sent) {
86
+ this.quoteId = null;
87
+ this.sentCount += 1;
88
+ return;
89
+ }
90
+ }
91
+ } catch (err) {
92
+ this.host.logger.warn(`[agent] markdown screenshot failed: ${err}`);
93
+ }
94
+ }
95
+ await this.deliverText(markdown ?? text);
96
+ }
97
+
98
+ async sendText(text: string): Promise<void> {
99
+ for (const unit of splitOutgoingUnits(text)) {
100
+ await this.deliverUnit(unit);
101
+ }
102
+ }
103
+
104
+ async onDelta(delta: string): Promise<void> {
105
+ this.streamBuffer += this.thinkFilter.push(delta, false);
106
+ await this.flush(false);
107
+ }
108
+
109
+ private async flush(force: boolean): Promise<void> {
110
+ while (true) {
111
+ const { units, rest } = consumeCompleteStreamUnits(this.streamBuffer, force);
112
+ if (units.length === 0) {
113
+ this.streamBuffer = rest;
114
+ break;
115
+ }
116
+ this.streamBuffer = rest;
117
+ for (const unit of units) {
118
+ await this.deliverUnit(unit);
119
+ }
120
+ if (!force) break;
121
+ }
122
+ }
123
+
124
+ async finishStream(fullText: string): Promise<void> {
125
+ this.streamBuffer += this.thinkFilter.push("", true);
126
+ await this.flush(true);
127
+ if (this.sentCount === 0 && fullText.trim()) {
128
+ await this.sendText(fullText);
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,58 @@
1
+ import type { AgentDatabase, AgentSessionRow } from "../db";
2
+
3
+ export class SessionManager {
4
+ constructor(private db: AgentDatabase) {}
5
+
6
+ generation(userId: number): number {
7
+ return this.db.getUserGeneration(userId);
8
+ }
9
+
10
+ sessionId(userId: number): string {
11
+ return `agent:${userId}:g${this.generation(userId)}`;
12
+ }
13
+
14
+ get(userId: number): AgentSessionRow {
15
+ return this.db.getOrCreateSession(this.sessionId(userId), userId);
16
+ }
17
+
18
+ newSession(userId: number): AgentSessionRow {
19
+ this.db.bumpUserGenerationTo(userId, this.db.maxGeneration(userId) + 1);
20
+ return this.get(userId);
21
+ }
22
+
23
+ resume(userId: number, generation: number): AgentSessionRow | undefined {
24
+ const target = this.db.getSessionByGeneration(userId, generation);
25
+ if (!target) return undefined;
26
+ this.db.bumpUserGenerationTo(userId, generation);
27
+ return this.db.getOrCreateSession(target.sessionId, userId);
28
+ }
29
+
30
+ resumableSessions(userId: number, excludeSessionId: string): AgentSessionRow[] {
31
+ return this.db
32
+ .listSessions(userId, { archived: false })
33
+ .filter((session) => session.sessionId !== excludeSessionId);
34
+ }
35
+
36
+ archive(userId: number, generation: number): AgentSessionRow | undefined {
37
+ const target = this.db.getSessionByGeneration(userId, generation);
38
+ if (!target) return undefined;
39
+ this.db.setSessionMeta(target.sessionId, { archived: true });
40
+ return this.db.getSessionByGeneration(userId, generation);
41
+ }
42
+
43
+ reset(userId: number): void {
44
+ this.db.resetSession(this.sessionId(userId));
45
+ }
46
+
47
+ history(session: AgentSessionRow) {
48
+ return this.db.getMessagesAfter(session.sessionId, session.summaryUpTo);
49
+ }
50
+
51
+ append(userId: number, role: "user" | "assistant", content: string): number {
52
+ return this.db.appendMessage(this.sessionId(userId), role, content);
53
+ }
54
+
55
+ setEmotion(userId: number, emotion: string): void {
56
+ this.db.setSessionMeta(this.sessionId(userId), { emotion });
57
+ }
58
+ }
package/core/title.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { AgentHost } from "../types";
2
+
3
+ const TITLE_MAX_CHARS = 40;
4
+ const TITLE_MAX_MESSAGES = 20;
5
+
6
+ function fallbackTitle(
7
+ messages: Array<{ role: string; content: string }>,
8
+ ): string {
9
+ const firstUser = messages.find((message) => message.role === "user");
10
+ const text = (firstUser?.content ?? "").replace(/\s+/g, " ").trim();
11
+ return text.slice(0, TITLE_MAX_CHARS) || "Untitled session";
12
+ }
13
+
14
+ export async function generateSessionTitle(
15
+ host: AgentHost,
16
+ sessionId: string,
17
+ ): Promise<string> {
18
+ const messages = host.db.getMessagesAfter(sessionId, 0);
19
+ if (messages.length === 0) return "Empty session";
20
+ const fallback = fallbackTitle(messages);
21
+
22
+ const resolved = host.resolveModel();
23
+ const worker = resolved?.working ?? resolved?.instance;
24
+ if (!worker) return fallback;
25
+
26
+ const transcript = messages
27
+ .slice(-TITLE_MAX_MESSAGES)
28
+ .map((message) => `${message.role === "user" ? "USER" : "AGENT"}: ${message.content}`)
29
+ .join("\n")
30
+ .slice(0, 6000);
31
+
32
+ try {
33
+ const response = await worker.complete({
34
+ model: resolved?.workingModel || resolved?.model || "",
35
+ messages: [
36
+ {
37
+ role: "system",
38
+ content:
39
+ "You name conversation sessions. Given a conversation transcript, write a short title that captures the main task or topic. Rules: at most 12 words, same language as the conversation, no quotes, no punctuation at the end, output ONLY the title.",
40
+ },
41
+ { role: "user", content: transcript },
42
+ ],
43
+ temperature: 0.3,
44
+ });
45
+ const title = (response.content ?? "").trim().replace(/^["'#\s]+|["'\s]+$/g, "");
46
+ if (!title) return fallback;
47
+ return title.slice(0, TITLE_MAX_CHARS);
48
+ } catch (err) {
49
+ host.logger.warn(`[agent] session title generation failed: ${err}`);
50
+ return fallback;
51
+ }
52
+ }