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/LICENSE +21 -0
- package/README.md +58 -0
- package/commands/index.ts +603 -0
- package/configs/base.ts +11 -0
- package/configs/context-window.ts +15 -0
- package/configs/settings.ts +39 -0
- package/core/activity.ts +236 -0
- package/core/attachment.ts +50 -0
- package/core/chat-config.ts +24 -0
- package/core/compaction.ts +97 -0
- package/core/download.ts +340 -0
- package/core/emotion.ts +47 -0
- package/core/loop.ts +597 -0
- package/core/media.ts +170 -0
- package/core/prompt.ts +214 -0
- package/core/risk.ts +61 -0
- package/core/send.ts +131 -0
- package/core/session.ts +58 -0
- package/core/title.ts +52 -0
- package/core/units.ts +208 -0
- package/db.ts +464 -0
- package/handlers/message.ts +13 -0
- package/index.ts +212 -0
- package/package.json +28 -0
- package/tools/approval.ts +119 -0
- package/tools/bash.ts +280 -0
- package/tools/deliver.ts +75 -0
- package/tools/fs.ts +296 -0
- package/tools/index.ts +194 -0
- package/tools/perm.ts +87 -0
- package/tools/todo.ts +136 -0
- package/tools/view-image.ts +85 -0
- package/tools/web.ts +133 -0
- package/tsconfig.json +7 -0
- package/types.ts +111 -0
- package/utils/config.ts +31 -0
- package/utils/json.ts +5 -0
package/core/units.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
export const MARKDOWN_OPEN_TAG = "<MARKDOWN>";
|
|
2
|
+
export const MARKDOWN_CLOSE_TAG = "</MARKDOWN>";
|
|
3
|
+
|
|
4
|
+
export function stripThinkBlocks(text: string): string {
|
|
5
|
+
let source = String(text ?? "");
|
|
6
|
+
let output = "";
|
|
7
|
+
while (source) {
|
|
8
|
+
const open = /<(?:think|thinking)\b[^>]*>/i.exec(source);
|
|
9
|
+
if (!open) {
|
|
10
|
+
output += source;
|
|
11
|
+
break;
|
|
12
|
+
}
|
|
13
|
+
output += source.slice(0, open.index);
|
|
14
|
+
const afterOpen = source.slice(open.index + open[0].length);
|
|
15
|
+
const close = /<\/(?:think|thinking)\s*>/i.exec(afterOpen);
|
|
16
|
+
if (!close) break;
|
|
17
|
+
source = afterOpen.slice(close.index + close[0].length);
|
|
18
|
+
}
|
|
19
|
+
return output.replace(/<\/?(?:think|thinking)\s*>/gi, "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createThinkTagStreamFilter() {
|
|
23
|
+
let buffer = "";
|
|
24
|
+
let insideThink = false;
|
|
25
|
+
|
|
26
|
+
const findOpen = (text: string) => {
|
|
27
|
+
const match = /<(?:think|thinking)\b[^>]*>/i.exec(text);
|
|
28
|
+
return match ? { index: match.index, end: match.index + match[0].length } : null;
|
|
29
|
+
};
|
|
30
|
+
const findClose = (text: string) => {
|
|
31
|
+
const match = /<\/(?:think|thinking)\s*>/i.exec(text);
|
|
32
|
+
return match ? { index: match.index, end: match.index + match[0].length } : null;
|
|
33
|
+
};
|
|
34
|
+
const keepSuffix = (text: string, tagPrefix: string) => {
|
|
35
|
+
const maxLength = Math.min(text.length, tagPrefix.length - 1);
|
|
36
|
+
const lowerText = text.toLowerCase();
|
|
37
|
+
const lowerPrefix = tagPrefix.toLowerCase();
|
|
38
|
+
for (let length = maxLength; length > 0; length--) {
|
|
39
|
+
if (lowerPrefix.startsWith(lowerText.slice(-length))) {
|
|
40
|
+
return text.slice(-length);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return "";
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
push(delta: string, force: boolean): string {
|
|
48
|
+
buffer += delta;
|
|
49
|
+
let output = "";
|
|
50
|
+
while (buffer) {
|
|
51
|
+
if (insideThink) {
|
|
52
|
+
const close = findClose(buffer);
|
|
53
|
+
if (!close) {
|
|
54
|
+
buffer = force ? "" : keepSuffix(buffer, "</thinking>");
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
buffer = buffer.slice(close.end);
|
|
58
|
+
insideThink = false;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const open = findOpen(buffer);
|
|
62
|
+
if (!open) {
|
|
63
|
+
const keep = force ? "" : keepSuffix(buffer, "<thinking>");
|
|
64
|
+
output += buffer.slice(0, buffer.length - keep.length);
|
|
65
|
+
buffer = keep;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
output += buffer.slice(0, open.index);
|
|
69
|
+
buffer = buffer.slice(open.end);
|
|
70
|
+
insideThink = true;
|
|
71
|
+
}
|
|
72
|
+
return output;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function splitOutgoingUnits(text: string): string[] {
|
|
78
|
+
const normalized = String(text ?? "").replace(/\r/g, "");
|
|
79
|
+
const result: string[] = [];
|
|
80
|
+
let buffer = "";
|
|
81
|
+
let insideMarkdown = false;
|
|
82
|
+
|
|
83
|
+
for (let index = 0; index < normalized.length; ) {
|
|
84
|
+
if (!insideMarkdown && normalized.startsWith(MARKDOWN_OPEN_TAG, index)) {
|
|
85
|
+
if (buffer.trim()) result.push(buffer.trim());
|
|
86
|
+
buffer = MARKDOWN_OPEN_TAG;
|
|
87
|
+
insideMarkdown = true;
|
|
88
|
+
index += MARKDOWN_OPEN_TAG.length;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (insideMarkdown && normalized.startsWith(MARKDOWN_CLOSE_TAG, index)) {
|
|
92
|
+
buffer += MARKDOWN_CLOSE_TAG;
|
|
93
|
+
if (buffer.trim()) result.push(buffer.trim());
|
|
94
|
+
buffer = "";
|
|
95
|
+
insideMarkdown = false;
|
|
96
|
+
index += MARKDOWN_CLOSE_TAG.length;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const char = normalized[index];
|
|
100
|
+
if (!insideMarkdown && char === "\n") {
|
|
101
|
+
if (buffer.trim()) result.push(buffer.trim());
|
|
102
|
+
buffer = "";
|
|
103
|
+
index += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
buffer += char;
|
|
107
|
+
index += 1;
|
|
108
|
+
}
|
|
109
|
+
if (buffer.trim()) result.push(buffer.trim());
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function takeNextStreamUnit(
|
|
114
|
+
input: string,
|
|
115
|
+
force: boolean,
|
|
116
|
+
): { unit: string; rest: string } | null {
|
|
117
|
+
const openIndex = input.indexOf(MARKDOWN_OPEN_TAG);
|
|
118
|
+
const newlineIndex = input.indexOf("\n");
|
|
119
|
+
|
|
120
|
+
if (openIndex === -1) {
|
|
121
|
+
if (newlineIndex >= 0) {
|
|
122
|
+
return { unit: input.slice(0, newlineIndex).trim(), rest: input.slice(newlineIndex + 1) };
|
|
123
|
+
}
|
|
124
|
+
if (force && input.trim()) return { unit: input.trim(), rest: "" };
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
if (newlineIndex >= 0 && newlineIndex < openIndex) {
|
|
128
|
+
return { unit: input.slice(0, newlineIndex).trim(), rest: input.slice(newlineIndex + 1) };
|
|
129
|
+
}
|
|
130
|
+
if (openIndex > 0) {
|
|
131
|
+
const prefix = input.slice(0, openIndex).trim();
|
|
132
|
+
return prefix
|
|
133
|
+
? { unit: prefix, rest: input.slice(openIndex) }
|
|
134
|
+
: { unit: "", rest: input.slice(openIndex) };
|
|
135
|
+
}
|
|
136
|
+
const closeIndex = input.indexOf(MARKDOWN_CLOSE_TAG, MARKDOWN_OPEN_TAG.length);
|
|
137
|
+
if (closeIndex < 0) {
|
|
138
|
+
if (force && input.trim()) return { unit: input.trim(), rest: "" };
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const endIndex = closeIndex + MARKDOWN_CLOSE_TAG.length;
|
|
142
|
+
const unit = input.slice(0, endIndex).trim();
|
|
143
|
+
let rest = input.slice(endIndex);
|
|
144
|
+
while (rest.startsWith("\n")) rest = rest.slice(1);
|
|
145
|
+
return { unit, rest };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function consumeCompleteStreamUnits(
|
|
149
|
+
buffer: string,
|
|
150
|
+
force: boolean,
|
|
151
|
+
): { units: string[]; rest: string } {
|
|
152
|
+
let rest = String(buffer ?? "").replace(/\r/g, "");
|
|
153
|
+
const units: string[] = [];
|
|
154
|
+
while (rest) {
|
|
155
|
+
while (rest.startsWith("\n")) rest = rest.slice(1);
|
|
156
|
+
if (!rest) break;
|
|
157
|
+
const next = takeNextStreamUnit(rest, force);
|
|
158
|
+
if (!next) break;
|
|
159
|
+
if (next.unit) units.push(next.unit);
|
|
160
|
+
rest = next.rest;
|
|
161
|
+
if (!force) break;
|
|
162
|
+
}
|
|
163
|
+
return { units, rest };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function extractStandaloneMarkdownBlock(text: string): string | null {
|
|
167
|
+
const trimmed = String(text ?? "").trim();
|
|
168
|
+
if (
|
|
169
|
+
!trimmed.startsWith(MARKDOWN_OPEN_TAG) ||
|
|
170
|
+
!trimmed.endsWith(MARKDOWN_CLOSE_TAG)
|
|
171
|
+
) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const inner = trimmed.slice(
|
|
175
|
+
MARKDOWN_OPEN_TAG.length,
|
|
176
|
+
trimmed.length - MARKDOWN_CLOSE_TAG.length,
|
|
177
|
+
);
|
|
178
|
+
return inner.trim() || null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function cleanEmotionMarkers(text: string): {
|
|
182
|
+
text: string;
|
|
183
|
+
emotion: string | null;
|
|
184
|
+
} {
|
|
185
|
+
const match = /\[emotion:([^\]\n]+)\]/i.exec(String(text ?? ""));
|
|
186
|
+
const emotion = match ? match[1].trim().toLowerCase() : null;
|
|
187
|
+
return {
|
|
188
|
+
text: String(text ?? "")
|
|
189
|
+
.replace(/\[emotion:[^\]\n]+\]/gi, "")
|
|
190
|
+
.replace(/\r/g, "")
|
|
191
|
+
.trim(),
|
|
192
|
+
emotion,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 提取 `[reply:message_id]` 引用标记:标记会被移除,id 用于给下一条消息加引用。 */
|
|
197
|
+
export function extractReplyMarker(text: string): {
|
|
198
|
+
text: string;
|
|
199
|
+
replyTo: string | null;
|
|
200
|
+
} {
|
|
201
|
+
const source = String(text ?? "");
|
|
202
|
+
const match = /\[reply:([^\]\n]+)\]/i.exec(source);
|
|
203
|
+
const replyTo = match ? match[1].trim() : null;
|
|
204
|
+
return {
|
|
205
|
+
text: source.replace(/\[reply:[^\]\n]+\]/gi, "").replace(/\r/g, "").trim(),
|
|
206
|
+
replyTo: replyTo || null,
|
|
207
|
+
};
|
|
208
|
+
}
|
package/db.ts
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { ensureDataDir } from "mioku";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
|
|
5
|
+
type SqlRow = Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
function rowNumber(
|
|
8
|
+
row: SqlRow | null | undefined,
|
|
9
|
+
key: string,
|
|
10
|
+
fallback: number,
|
|
11
|
+
): number {
|
|
12
|
+
const value = Number(row?.[key]);
|
|
13
|
+
return Number.isFinite(value) ? value : fallback;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function rowString(row: SqlRow, key: string, fallback = ""): string {
|
|
17
|
+
const value = row[key];
|
|
18
|
+
return typeof value === "string" ? value : fallback;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type SessionPlanStatus = "pending" | "in_progress" | "completed";
|
|
22
|
+
|
|
23
|
+
export interface SessionPlanItem {
|
|
24
|
+
content: string;
|
|
25
|
+
status: SessionPlanStatus;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AgentSessionRow {
|
|
29
|
+
sessionId: string;
|
|
30
|
+
userId: number;
|
|
31
|
+
generation: number;
|
|
32
|
+
emotion: string;
|
|
33
|
+
summary: string;
|
|
34
|
+
summaryUpTo: number;
|
|
35
|
+
title: string;
|
|
36
|
+
archived: boolean;
|
|
37
|
+
goal: string;
|
|
38
|
+
plan: SessionPlanItem[];
|
|
39
|
+
createdAt: number;
|
|
40
|
+
updatedAt: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AgentMessageRow {
|
|
44
|
+
id: number;
|
|
45
|
+
sessionId: string;
|
|
46
|
+
role: "user" | "assistant";
|
|
47
|
+
content: string;
|
|
48
|
+
createdAt: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AgentRunRow {
|
|
52
|
+
id: number;
|
|
53
|
+
sessionId: string;
|
|
54
|
+
userId: number;
|
|
55
|
+
model: string;
|
|
56
|
+
status: "ok" | "error";
|
|
57
|
+
iterations: number;
|
|
58
|
+
toolCallCount: number;
|
|
59
|
+
durationMs: number;
|
|
60
|
+
error: string;
|
|
61
|
+
startedAt: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AgentToolCallRow {
|
|
65
|
+
id: number;
|
|
66
|
+
runId: number;
|
|
67
|
+
name: string;
|
|
68
|
+
args: string;
|
|
69
|
+
resultChars: number;
|
|
70
|
+
durationMs: number;
|
|
71
|
+
ok: number;
|
|
72
|
+
error: string;
|
|
73
|
+
createdAt: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ClearSessionsResult {
|
|
77
|
+
sessions: number;
|
|
78
|
+
messages: number;
|
|
79
|
+
runs: number;
|
|
80
|
+
toolCalls: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class AgentDatabase {
|
|
84
|
+
private db: Database;
|
|
85
|
+
|
|
86
|
+
constructor(dbPath: string) {
|
|
87
|
+
this.db = new Database(dbPath);
|
|
88
|
+
this.db.run("PRAGMA journal_mode = WAL;");
|
|
89
|
+
this.migrate();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private migrate(): void {
|
|
93
|
+
this.db.run(`
|
|
94
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
95
|
+
session_id TEXT PRIMARY KEY,
|
|
96
|
+
user_id INTEGER NOT NULL,
|
|
97
|
+
generation INTEGER NOT NULL DEFAULT 0,
|
|
98
|
+
emotion TEXT NOT NULL DEFAULT '',
|
|
99
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
100
|
+
summary_up_to INTEGER NOT NULL DEFAULT 0,
|
|
101
|
+
title TEXT NOT NULL DEFAULT '',
|
|
102
|
+
archived INTEGER NOT NULL DEFAULT 0,
|
|
103
|
+
goal TEXT NOT NULL DEFAULT '',
|
|
104
|
+
plan TEXT NOT NULL DEFAULT '[]',
|
|
105
|
+
created_at INTEGER NOT NULL,
|
|
106
|
+
updated_at INTEGER NOT NULL
|
|
107
|
+
);
|
|
108
|
+
CREATE TABLE IF NOT EXISTS user_state (
|
|
109
|
+
user_id INTEGER PRIMARY KEY,
|
|
110
|
+
generation INTEGER NOT NULL DEFAULT 0,
|
|
111
|
+
updated_at INTEGER NOT NULL
|
|
112
|
+
);
|
|
113
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
114
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
115
|
+
session_id TEXT NOT NULL,
|
|
116
|
+
role TEXT NOT NULL,
|
|
117
|
+
content TEXT NOT NULL,
|
|
118
|
+
created_at INTEGER NOT NULL
|
|
119
|
+
);
|
|
120
|
+
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, id);
|
|
121
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
122
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
123
|
+
session_id TEXT NOT NULL,
|
|
124
|
+
user_id INTEGER NOT NULL,
|
|
125
|
+
model TEXT NOT NULL DEFAULT '',
|
|
126
|
+
status TEXT NOT NULL DEFAULT 'ok',
|
|
127
|
+
iterations INTEGER NOT NULL DEFAULT 0,
|
|
128
|
+
tool_call_count INTEGER NOT NULL DEFAULT 0,
|
|
129
|
+
duration_ms INTEGER NOT NULL DEFAULT 0,
|
|
130
|
+
error TEXT NOT NULL DEFAULT '',
|
|
131
|
+
started_at INTEGER NOT NULL
|
|
132
|
+
);
|
|
133
|
+
CREATE TABLE IF NOT EXISTS tool_calls (
|
|
134
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
135
|
+
run_id INTEGER NOT NULL,
|
|
136
|
+
name TEXT NOT NULL,
|
|
137
|
+
args TEXT NOT NULL DEFAULT '',
|
|
138
|
+
result_chars INTEGER NOT NULL DEFAULT 0,
|
|
139
|
+
duration_ms INTEGER NOT NULL DEFAULT 0,
|
|
140
|
+
ok INTEGER NOT NULL DEFAULT 1,
|
|
141
|
+
error TEXT NOT NULL DEFAULT '',
|
|
142
|
+
created_at INTEGER NOT NULL
|
|
143
|
+
);
|
|
144
|
+
`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
getUserGeneration(userId: number): number {
|
|
148
|
+
const row = this.db
|
|
149
|
+
.query("SELECT generation FROM user_state WHERE user_id = ?")
|
|
150
|
+
.get(userId) as SqlRow | null;
|
|
151
|
+
return rowNumber(row, "generation", 0);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
maxGeneration(userId: number): number {
|
|
155
|
+
const row = this.db
|
|
156
|
+
.query("SELECT MAX(generation) AS max FROM sessions WHERE user_id = ?")
|
|
157
|
+
.get(userId) as SqlRow | null;
|
|
158
|
+
return rowNumber(row, "max", -1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
bumpUserGenerationTo(userId: number, generation: number): void {
|
|
162
|
+
this.db.run(
|
|
163
|
+
`INSERT INTO user_state (user_id, generation, updated_at) VALUES (?, ?, ?)
|
|
164
|
+
ON CONFLICT(user_id) DO UPDATE SET generation = ?, updated_at = ?`,
|
|
165
|
+
[userId, generation, Date.now(), generation, Date.now()],
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
getOrCreateSession(sessionId: string, userId: number): AgentSessionRow {
|
|
170
|
+
const now = Date.now();
|
|
171
|
+
this.db.run(
|
|
172
|
+
`INSERT INTO sessions (session_id, user_id, generation, created_at, updated_at)
|
|
173
|
+
VALUES (?, ?, ?, ?, ?)
|
|
174
|
+
ON CONFLICT(session_id) DO UPDATE SET updated_at = ?`,
|
|
175
|
+
[sessionId, userId, parseGeneration(sessionId), now, now, now],
|
|
176
|
+
);
|
|
177
|
+
const row = this.db
|
|
178
|
+
.query("SELECT * FROM sessions WHERE session_id = ?")
|
|
179
|
+
.get(sessionId) as SqlRow | null;
|
|
180
|
+
return toSessionRow(row ?? {});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
getSessionByGeneration(
|
|
184
|
+
userId: number,
|
|
185
|
+
generation: number,
|
|
186
|
+
): AgentSessionRow | undefined {
|
|
187
|
+
const row = this.db
|
|
188
|
+
.query("SELECT * FROM sessions WHERE user_id = ? AND generation = ?")
|
|
189
|
+
.get(userId, generation) as SqlRow | null;
|
|
190
|
+
return row ? toSessionRow(row) : undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
listSessions(
|
|
194
|
+
userId: number,
|
|
195
|
+
options: { archived?: boolean } = {},
|
|
196
|
+
): AgentSessionRow[] {
|
|
197
|
+
const rows = options.archived === undefined
|
|
198
|
+
? (this.db
|
|
199
|
+
.query("SELECT * FROM sessions WHERE user_id = ? ORDER BY updated_at DESC")
|
|
200
|
+
.all(userId) as SqlRow[])
|
|
201
|
+
: (this.db
|
|
202
|
+
.query(
|
|
203
|
+
"SELECT * FROM sessions WHERE user_id = ? AND archived = ? ORDER BY updated_at DESC",
|
|
204
|
+
)
|
|
205
|
+
.all(userId, options.archived ? 1 : 0) as SqlRow[]);
|
|
206
|
+
return rows.map(toSessionRow);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
setSessionMeta(
|
|
210
|
+
sessionId: string,
|
|
211
|
+
patch: {
|
|
212
|
+
title?: string;
|
|
213
|
+
archived?: boolean;
|
|
214
|
+
goal?: string;
|
|
215
|
+
plan?: SessionPlanItem[];
|
|
216
|
+
emotion?: string;
|
|
217
|
+
summary?: string;
|
|
218
|
+
summaryUpTo?: number;
|
|
219
|
+
},
|
|
220
|
+
): void {
|
|
221
|
+
const sets: string[] = [];
|
|
222
|
+
const values: Array<string | number | null> = [];
|
|
223
|
+
if (patch.title !== undefined) {
|
|
224
|
+
sets.push("title = ?");
|
|
225
|
+
values.push(patch.title);
|
|
226
|
+
}
|
|
227
|
+
if (patch.archived !== undefined) {
|
|
228
|
+
sets.push("archived = ?");
|
|
229
|
+
values.push(patch.archived ? 1 : 0);
|
|
230
|
+
}
|
|
231
|
+
if (patch.goal !== undefined) {
|
|
232
|
+
sets.push("goal = ?");
|
|
233
|
+
values.push(patch.goal);
|
|
234
|
+
}
|
|
235
|
+
if (patch.plan !== undefined) {
|
|
236
|
+
sets.push("plan = ?");
|
|
237
|
+
values.push(JSON.stringify(patch.plan));
|
|
238
|
+
}
|
|
239
|
+
if (patch.emotion !== undefined) {
|
|
240
|
+
sets.push("emotion = ?");
|
|
241
|
+
values.push(patch.emotion);
|
|
242
|
+
}
|
|
243
|
+
if (patch.summary !== undefined || patch.summaryUpTo !== undefined) {
|
|
244
|
+
sets.push("summary = COALESCE(?, summary)");
|
|
245
|
+
sets.push("summary_up_to = COALESCE(?, summary_up_to)");
|
|
246
|
+
values.push(patch.summary ?? null, patch.summaryUpTo ?? null);
|
|
247
|
+
}
|
|
248
|
+
if (sets.length === 0) return;
|
|
249
|
+
sets.push("updated_at = ?");
|
|
250
|
+
values.push(Date.now(), sessionId);
|
|
251
|
+
this.db.run(`UPDATE sessions SET ${sets.join(", ")} WHERE session_id = ?`, values);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
touchSession(sessionId: string): void {
|
|
255
|
+
this.db.run("UPDATE sessions SET updated_at = ? WHERE session_id = ?", [
|
|
256
|
+
Date.now(),
|
|
257
|
+
sessionId,
|
|
258
|
+
]);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
setEmotion(sessionId: string, emotion: string): void {
|
|
262
|
+
this.db.run("UPDATE sessions SET emotion = ? WHERE session_id = ?", [
|
|
263
|
+
emotion,
|
|
264
|
+
sessionId,
|
|
265
|
+
]);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
setSummary(sessionId: string, summary: string, upTo: number): void {
|
|
269
|
+
this.db.run(
|
|
270
|
+
"UPDATE sessions SET summary = ?, summary_up_to = ? WHERE session_id = ?",
|
|
271
|
+
[summary, upTo, sessionId],
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
appendMessage(sessionId: string, role: "user" | "assistant", content: string): number {
|
|
276
|
+
const now = Date.now();
|
|
277
|
+
const result = this.db
|
|
278
|
+
.query(
|
|
279
|
+
"INSERT INTO messages (session_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
|
280
|
+
)
|
|
281
|
+
.run(sessionId, role, content, now);
|
|
282
|
+
this.touchSession(sessionId);
|
|
283
|
+
return Number(result.lastInsertRowid);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
getMessagesAfter(sessionId: string, afterId: number): AgentMessageRow[] {
|
|
287
|
+
const rows = this.db
|
|
288
|
+
.query(
|
|
289
|
+
"SELECT * FROM messages WHERE session_id = ? AND id > ? ORDER BY id ASC",
|
|
290
|
+
)
|
|
291
|
+
.all(sessionId, afterId) as SqlRow[];
|
|
292
|
+
return rows.map((row) => ({
|
|
293
|
+
id: rowNumber(row, "id", 0),
|
|
294
|
+
sessionId: rowString(row, "session_id"),
|
|
295
|
+
role: rowString(row, "role") === "assistant" ? "assistant" : "user",
|
|
296
|
+
content: rowString(row, "content"),
|
|
297
|
+
createdAt: rowNumber(row, "created_at", 0),
|
|
298
|
+
}));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
countMessages(sessionId: string): number {
|
|
302
|
+
const row = this.db
|
|
303
|
+
.query("SELECT COUNT(*) AS count FROM messages WHERE session_id = ?")
|
|
304
|
+
.get(sessionId) as SqlRow | null;
|
|
305
|
+
return rowNumber(row, "count", 0);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
resetSession(sessionId: string): void {
|
|
309
|
+
this.db.run("DELETE FROM messages WHERE session_id = ?", [sessionId]);
|
|
310
|
+
this.db.run(
|
|
311
|
+
"UPDATE sessions SET summary = '', summary_up_to = 0, emotion = '', updated_at = ? WHERE session_id = ?",
|
|
312
|
+
[Date.now(), sessionId],
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** 删除该用户的全部会话(含归档)及其消息、运行记录与工具调用明细。 */
|
|
317
|
+
clearUserSessions(userId: number): ClearSessionsResult {
|
|
318
|
+
const ids = this.listSessions(userId).map((session) => session.sessionId);
|
|
319
|
+
const result: ClearSessionsResult = {
|
|
320
|
+
sessions: ids.length,
|
|
321
|
+
messages: 0,
|
|
322
|
+
runs: 0,
|
|
323
|
+
toolCalls: 0,
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
if (ids.length > 0) {
|
|
327
|
+
const marks = ids.map(() => "?").join(", ");
|
|
328
|
+
result.messages = this.countRows(
|
|
329
|
+
"messages",
|
|
330
|
+
`session_id IN (${marks})`,
|
|
331
|
+
ids,
|
|
332
|
+
);
|
|
333
|
+
result.runs = this.countRows("runs", "user_id = ?", [userId]);
|
|
334
|
+
result.toolCalls = this.countRows(
|
|
335
|
+
"tool_calls",
|
|
336
|
+
"run_id IN (SELECT id FROM runs WHERE user_id = ?)",
|
|
337
|
+
[userId],
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
this.db.run(
|
|
341
|
+
"DELETE FROM tool_calls WHERE run_id IN (SELECT id FROM runs WHERE user_id = ?)",
|
|
342
|
+
[userId],
|
|
343
|
+
);
|
|
344
|
+
this.db.run("DELETE FROM runs WHERE user_id = ?", [userId]);
|
|
345
|
+
this.db.run(`DELETE FROM messages WHERE session_id IN (${marks})`, ids);
|
|
346
|
+
this.db.run("DELETE FROM sessions WHERE user_id = ?", [userId]);
|
|
347
|
+
}
|
|
348
|
+
this.db.run("DELETE FROM user_state WHERE user_id = ?", [userId]);
|
|
349
|
+
return result;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private countRows(
|
|
353
|
+
table: "messages" | "runs" | "tool_calls",
|
|
354
|
+
where: string,
|
|
355
|
+
params: Array<string | number>,
|
|
356
|
+
): number {
|
|
357
|
+
const row = this.db
|
|
358
|
+
.query(`SELECT COUNT(*) AS count FROM ${table} WHERE ${where}`)
|
|
359
|
+
.get(...params) as SqlRow | null;
|
|
360
|
+
return rowNumber(row, "count", 0);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
startRun(sessionId: string, userId: number, model: string): number {
|
|
364
|
+
const result = this.db
|
|
365
|
+
.query(
|
|
366
|
+
"INSERT INTO runs (session_id, user_id, model, started_at) VALUES (?, ?, ?, ?)",
|
|
367
|
+
)
|
|
368
|
+
.run(sessionId, userId, model, Date.now());
|
|
369
|
+
return Number(result.lastInsertRowid);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
finishRun(
|
|
373
|
+
runId: number,
|
|
374
|
+
status: "ok" | "error",
|
|
375
|
+
iterations: number,
|
|
376
|
+
toolCallCount: number,
|
|
377
|
+
durationMs: number,
|
|
378
|
+
error = "",
|
|
379
|
+
): void {
|
|
380
|
+
this.db.run(
|
|
381
|
+
"UPDATE runs SET status = ?, iterations = ?, tool_call_count = ?, duration_ms = ?, error = ? WHERE id = ?",
|
|
382
|
+
[status, iterations, toolCallCount, durationMs, error, runId],
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
recordToolCall(
|
|
387
|
+
runId: number,
|
|
388
|
+
name: string,
|
|
389
|
+
args: unknown,
|
|
390
|
+
resultChars: number,
|
|
391
|
+
durationMs: number,
|
|
392
|
+
ok: boolean,
|
|
393
|
+
error = "",
|
|
394
|
+
): void {
|
|
395
|
+
this.db
|
|
396
|
+
.query(
|
|
397
|
+
"INSERT INTO tool_calls (run_id, name, args, result_chars, duration_ms, ok, error, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
398
|
+
)
|
|
399
|
+
.run(
|
|
400
|
+
runId,
|
|
401
|
+
name,
|
|
402
|
+
JSON.stringify(args ?? {}).slice(0, 4000),
|
|
403
|
+
resultChars,
|
|
404
|
+
durationMs,
|
|
405
|
+
ok ? 1 : 0,
|
|
406
|
+
error.slice(0, 2000),
|
|
407
|
+
Date.now(),
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
getRunStats(userId: number): { runs: number; toolCalls: number } {
|
|
412
|
+
const runRow = this.db
|
|
413
|
+
.query("SELECT COUNT(*) AS count FROM runs WHERE user_id = ?")
|
|
414
|
+
.get(userId) as SqlRow | null;
|
|
415
|
+
const toolRow = this.db
|
|
416
|
+
.query(
|
|
417
|
+
"SELECT COUNT(*) AS count FROM tool_calls tc JOIN runs r ON tc.run_id = r.id WHERE r.user_id = ?",
|
|
418
|
+
)
|
|
419
|
+
.get(userId) as SqlRow | null;
|
|
420
|
+
return {
|
|
421
|
+
runs: rowNumber(runRow, "count", 0),
|
|
422
|
+
toolCalls: rowNumber(toolRow, "count", 0),
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
close(): void {
|
|
427
|
+
this.db.close();
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export async function initDatabase(): Promise<AgentDatabase> {
|
|
432
|
+
const dir = ensureDataDir("agent");
|
|
433
|
+
return new AgentDatabase(path.join(dir, "agent.db"));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function parseGeneration(sessionId: string): number {
|
|
437
|
+
const match = /:g(\d+)$/.exec(sessionId);
|
|
438
|
+
return match ? Number(match[1]) : 0;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function toSessionRow(row: SqlRow): AgentSessionRow {
|
|
442
|
+
let plan: SessionPlanItem[] = [];
|
|
443
|
+
try {
|
|
444
|
+
const parsed = JSON.parse(rowString(row, "plan", "[]"));
|
|
445
|
+
if (Array.isArray(parsed)) plan = parsed;
|
|
446
|
+
} catch {
|
|
447
|
+
plan = [];
|
|
448
|
+
}
|
|
449
|
+
const sessionId = rowString(row, "session_id");
|
|
450
|
+
return {
|
|
451
|
+
sessionId,
|
|
452
|
+
userId: rowNumber(row, "user_id", 0),
|
|
453
|
+
generation: rowNumber(row, "generation", parseGeneration(sessionId)),
|
|
454
|
+
emotion: rowString(row, "emotion"),
|
|
455
|
+
summary: rowString(row, "summary"),
|
|
456
|
+
summaryUpTo: rowNumber(row, "summary_up_to", 0),
|
|
457
|
+
title: rowString(row, "title"),
|
|
458
|
+
archived: Boolean(rowNumber(row, "archived", 0)),
|
|
459
|
+
goal: rowString(row, "goal"),
|
|
460
|
+
plan,
|
|
461
|
+
createdAt: rowNumber(row, "created_at", 0),
|
|
462
|
+
updatedAt: rowNumber(row, "updated_at", 0),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { MessageEvent } from "mioku";
|
|
2
|
+
import type { AgentHost } from "../types";
|
|
3
|
+
import { runAgentTurn } from "../core/loop";
|
|
4
|
+
|
|
5
|
+
export function createMessageHandler(host: AgentHost) {
|
|
6
|
+
return async (e: MessageEvent) => {
|
|
7
|
+
if (e.message_type === "group") return;
|
|
8
|
+
const userId = Number(e.user_id || e.sender?.user_id || 0);
|
|
9
|
+
if (!userId || userId === Number(e.self_id || 0)) return;
|
|
10
|
+
if (!(await host.isAllowed(userId))) return;
|
|
11
|
+
await runAgentTurn(host, e);
|
|
12
|
+
};
|
|
13
|
+
}
|