mioku-plugin-agent 0.1.1 → 0.2.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 CHANGED
@@ -12,11 +12,11 @@
12
12
  - `auto` 自动:文件与命令直接执行,但**每条命令先由工作模型审查**,危险操作(删除用户文件、清库、`git push`、sudo 等)转用户审批;执行通知与审批请求都即时推送
13
13
  - `full` 完全访问:不审批,命令与写入/编辑汇总成一条转发记录
14
14
  - `yolo` 静默:权限最高且**不推送任何中间通知**,用户只收到最终结果
15
- - **个人工作区**:每个 QQ 号独立工作区,默认 `data/agent/workspace/<qq号>`
15
+ - **个人工作区**:每个「适配器 + 用户」独立工作区,默认 `data/agent/workspace/<适配器>_<用户id>`(openid 平台同样隔离)
16
16
  - **聊天内审批**:非 full 权限档下 bash 命令需 `.agent approve` 批准(不带 id 时处理该用户最近一次请求);每条命令都必须带 `purpose`,审批与执行告知都会带上用途
17
17
  - **操作合并转发**(仅 `full`):命令、写入、编辑不再逐条推送,而是攒到本轮结束、在最终回复**之前**合并成一条合并转发消息(卡片来源「Agent 执行记录」,外显小字是操作条数与用户请求摘要,总摘要是各类操作计数与失败数;首节点为简介,其余节点按时间顺序记录每条命令/文件改动,含用途、耗时与失败输出)。适配器不支持转发时自动降级为普通文本消息。`read`/`glob`/`grep`/联网/看图等只读操作不记录。`auto` 不合并,仍逐条即时推送
18
18
  - **工具面**:read / write / edit / glob / grep / bash / view_image / send_file / send_image / web_search (SearXNG) / web_fetch / todo_write
19
- - **附件自动下载**:用户发来的图片/文件/音视频统一按文件自动落到 `download/<今天日期>/`,**保留原始文件名与后缀**(`file_name` → segment 的 `file` → `path` → URL 文件名,缺后缀才用 content-type 补);user 消息里带 `message_id`、`name` 和 `[file://路径]`,图片额外作为图片内容附加给模型。QQ 文件消息只有 `file_id` 时按平台 API(`get_file` / `get_group_file_url` / `get_private_file_url`)换取下载地址与原始文件名
19
+ - **附件自动下载**:用户发来的图片/文件/音视频统一按文件自动落到 `download/<今天日期>/`,**保留原始文件名与后缀**(`file_name` → segment 的 `file` → `path` → URL 文件名,缺后缀才用 content-type 补);user 消息里带 `message_id`、`name` 和 `[file://路径]`,图片额外作为图片内容附加给模型。文件消息只有 `file_id` 时按平台分支解析下载地址,见下
20
20
  - **消息引用**:模型在回复首行写 `[reply:message_id]` 即可引用(回复)指定聊天消息,标记会被移除并只作用于本轮第一条消息
21
21
  - **运行中插话**:Agent 正在跑时用户继续发消息,不再排队等下一轮,而是并入**当前请求**的下一次迭代(DSH 式 steering),模型在同一个回复里就能看到并调整;日志里 `steer queued` 表示已入队、`steer merged into running turn` 表示已并入本轮请求
22
22
  - **看图**:`view_image` 查看本地图片;多模态主模型直接把图片附加进对话,非多模态时交给视觉模型转成描述
package/commands/index.ts CHANGED
@@ -4,15 +4,16 @@ import type { SessionPlanItem } from "../db";
4
4
  import { maybeCompact } from "../core/compaction";
5
5
  import { generateSessionTitle } from "../core/title";
6
6
  import { stopAgentTurn } from "../core/loop";
7
+ import { identityOf } from "../core/identity";
7
8
  import { PERMISSION_LEVELS } from "../tools/perm";
8
9
 
9
10
  const CLEAR_CONFIRM_TTL_MS = 60_000;
10
11
 
11
12
  const resumeListCache = new Map<
12
- number,
13
+ string,
13
14
  { generations: number[]; at: number }
14
15
  >();
15
- const pendingClear = new Map<number, number>();
16
+ const pendingClear = new Map<string, number>();
16
17
 
17
18
  async function reply(event: MessageEvent, text: string): Promise<void> {
18
19
  await event.reply(text, true);
@@ -21,12 +22,12 @@ async function reply(event: MessageEvent, text: string): Promise<void> {
21
22
  async function requireUser(
22
23
  host: AgentHost,
23
24
  event: MessageEvent,
24
- ): Promise<number> {
25
- const userId = Number(event.user_id || event.sender?.user_id || 0);
26
- if (!userId) {
27
- await reply(event, "agent 命令需要在 QQ 私聊中使用");
25
+ ): Promise<string> {
26
+ const identity = identityOf(event);
27
+ if (!identity.userId) {
28
+ await reply(event, "agent 命令需要在私聊中使用");
28
29
  }
29
- return userId;
30
+ return identity.scope;
30
31
  }
31
32
 
32
33
  function formatTime(ts: number): string {
@@ -56,7 +57,7 @@ async function backgroundTitle(
56
57
  host.logger.info(`[agent] session ${sessionId} titled: ${title}`);
57
58
  }
58
59
 
59
- function cachedResumable(host: AgentHost, userId: number): number[] {
60
+ function cachedResumable(host: AgentHost, userId: string): number[] {
60
61
  const cached = resumeListCache.get(userId);
61
62
  if (cached && Date.now() - cached.at < 5 * 60_000) return cached.generations;
62
63
  const current = host.sessions.sessionId(userId);
@@ -67,7 +68,7 @@ function cachedResumable(host: AgentHost, userId: number): number[] {
67
68
  return generations;
68
69
  }
69
70
 
70
- function invalidateResumeCache(userId: number): void {
71
+ function invalidateResumeCache(userId: string): void {
71
72
  resumeListCache.delete(userId);
72
73
  }
73
74
 
@@ -34,7 +34,7 @@ export interface CompactionResult {
34
34
 
35
35
  export async function maybeCompact(
36
36
  host: AgentHost,
37
- userId: number,
37
+ userId: string,
38
38
  options: { force?: boolean } = {},
39
39
  ): Promise<CompactionResult> {
40
40
  const settings = host.getSettings();
package/core/download.ts CHANGED
@@ -3,6 +3,8 @@ import * as fsp from "node:fs/promises";
3
3
  import * as path from "node:path";
4
4
  import type { Bot } from "mioku";
5
5
  import type { MediaAttachment, MediaKind } from "./media";
6
+ import type { AgentPlatform } from "../platforms/types";
7
+ import { EMPTY_FILE_LOOKUP } from "../platforms/types";
6
8
 
7
9
  const MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
8
10
  const DOWNLOAD_TIMEOUT_MS = 60_000;
@@ -212,52 +214,18 @@ function candidateSources(item: MediaAttachment): string[] {
212
214
  async function platformLookup(
213
215
  bot: Bot | undefined,
214
216
  item: MediaAttachment,
217
+ platform: AgentPlatform | undefined,
215
218
  ): Promise<{ sources: string[]; names: string[] }> {
216
- const sources: string[] = [];
217
- const names: string[] = [];
218
- if (!bot || !item.fileId) return { sources, names };
219
-
220
- const attempts: Array<[string, Record<string, unknown>]> = [
221
- ["get_file", { file_id: item.fileId }],
222
- ];
223
- if (item.groupId) {
224
- attempts.push([
225
- "get_group_file_url",
226
- { group_id: Number(item.groupId), file_id: item.fileId },
227
- ]);
228
- }
229
- if (item.userId) {
230
- attempts.push([
231
- "get_private_file_url",
232
- { user_id: Number(item.userId), file_id: item.fileId },
233
- ]);
234
- }
235
-
236
- for (const [action, params] of attempts) {
237
- try {
238
- const result = (await bot.sendApi(action, params)) as Record<
239
- string,
240
- unknown
241
- > | null;
242
- if (!result || typeof result !== "object") continue;
243
- for (const key of ["url", "file", "path"]) {
244
- const value = result[key];
245
- if (typeof value === "string" && value.trim())
246
- sources.push(value.trim());
247
- }
248
- const base64 = result.base64 ?? result.data;
249
- if (typeof base64 === "string" && base64.trim()) {
250
- sources.push(`base64://${base64.trim()}`);
251
- }
252
- for (const key of ["file_name", "name"]) {
253
- const value = result[key];
254
- if (typeof value === "string" && value.trim()) names.push(value.trim());
255
- }
256
- } catch {
257
- // 平台不支持该 action 时忽略,继续尝试下一个
258
- }
219
+ if (!bot || !item.fileId || !platform) return EMPTY_FILE_LOOKUP;
220
+ try {
221
+ return await platform.resolveFile(bot, {
222
+ fileId: item.fileId,
223
+ groupId: item.groupId,
224
+ userId: item.userId,
225
+ });
226
+ } catch {
227
+ return EMPTY_FILE_LOOKUP;
259
228
  }
260
- return { sources, names };
261
229
  }
262
230
 
263
231
  function describeFields(item: MediaAttachment): string {
@@ -273,13 +241,14 @@ function describeFields(item: MediaAttachment): string {
273
241
  async function resolveSource(
274
242
  item: MediaAttachment,
275
243
  bot: Bot | undefined,
244
+ platform: AgentPlatform | undefined,
276
245
  ): Promise<{
277
246
  buffer: Buffer;
278
247
  contentType: string | null;
279
248
  fallbackName: string;
280
249
  }> {
281
- const platform = await platformLookup(bot, item);
282
- const candidates = [...candidateSources(item), ...platform.sources];
250
+ const found = await platformLookup(bot, item, platform);
251
+ const candidates = [...candidateSources(item), ...found.sources];
283
252
  if (candidates.length === 0) {
284
253
  throw new Error(`no downloadable source (${describeFields(item)})`);
285
254
  }
@@ -290,7 +259,7 @@ async function resolveSource(
290
259
  // 平台返回的原始文件名最可信,其次才是 URL 推断出来的名字
291
260
  return {
292
261
  ...result,
293
- fallbackName: platform.names[0] ?? result.fallbackName,
262
+ fallbackName: found.names[0] ?? result.fallbackName,
294
263
  };
295
264
  } catch (err) {
296
265
  lastError = String(err);
@@ -302,7 +271,7 @@ async function resolveSource(
302
271
  export async function downloadMediaItems(
303
272
  items: MediaAttachment[],
304
273
  workspaceRoot: string,
305
- options: { bot?: Bot } = {},
274
+ options: { bot?: Bot; platform?: AgentPlatform } = {},
306
275
  ): Promise<DownloadResult> {
307
276
  const dir = path.join(workspaceRoot, "download", dateStamp());
308
277
  const files: DownloadedMedia[] = [];
@@ -315,6 +284,7 @@ export async function downloadMediaItems(
315
284
  const { buffer, contentType, fallbackName } = await resolveSource(
316
285
  item,
317
286
  options.bot,
287
+ options.platform,
318
288
  );
319
289
  if (buffer.byteLength > MAX_DOWNLOAD_BYTES) {
320
290
  errors.push(`#${index + 1} exceeds ${MAX_DOWNLOAD_BYTES} bytes`);
package/core/emotion.ts CHANGED
@@ -8,7 +8,7 @@ function normalizeName(value: unknown): string {
8
8
 
9
9
  export class EmotionManager {
10
10
  constructor(
11
- private readonly store: (userId: number, emotion: string) => void,
11
+ private readonly store: (userId: string, emotion: string) => void,
12
12
  ) {}
13
13
 
14
14
  available(config: ChatEmotionConfig | null): string[] {
@@ -36,7 +36,7 @@ export class EmotionManager {
36
36
  }
37
37
 
38
38
  setEmotion(
39
- userId: number,
39
+ userId: string,
40
40
  emotion: unknown,
41
41
  config: ChatEmotionConfig | null,
42
42
  ): string {
@@ -0,0 +1,27 @@
1
+ import type { MessageEvent } from "mioku";
2
+
3
+ /**
4
+ * 事件的用户身份。
5
+ * `userId` 是平台原始 id(QQ 号或 openid),用于权限名单匹配;
6
+ * `scope` 额外带上适配器,用于会话/工作区/队列隔离,避免不同平台相同 id 串号。
7
+ */
8
+ export interface AgentIdentity {
9
+ readonly userId: string;
10
+ readonly adapter: string;
11
+ readonly botId: string;
12
+ readonly scope: string;
13
+ }
14
+
15
+ export const identityOf = (event: MessageEvent): AgentIdentity => {
16
+ const userId = String(event?.user_id ?? event?.sender?.user_id ?? "").trim();
17
+ const adapter = String(
18
+ event?.bot?.adapter ?? event?.identity?.adapter ?? "",
19
+ ).trim();
20
+ const botId = String(event?.self_id ?? event?.bot?.bot_id ?? "").trim();
21
+ return {
22
+ userId,
23
+ adapter,
24
+ botId,
25
+ scope: `${adapter || "unknown"}:${userId}`,
26
+ };
27
+ };
package/core/loop.ts CHANGED
@@ -12,6 +12,8 @@ import { TurnSender } from "./send";
12
12
  import { buildSystemPrompt } from "./prompt";
13
13
  import { maybeCompact } from "./compaction";
14
14
  import { cleanEmotionMarkers, stripThinkBlocks } from "./units";
15
+ import { identityOf, type AgentIdentity } from "./identity";
16
+ import type { AgentPlatform } from "../platforms/types";
15
17
  import { describeImageUrls, extractMedia, formatMediaNote } from "./media";
16
18
  import { downloadMediaItems, readImageDataUrl } from "./download";
17
19
  import { TurnActivity } from "./activity";
@@ -42,9 +44,9 @@ interface UserQueue {
42
44
  controller: AbortController | null;
43
45
  }
44
46
 
45
- const queues = new Map<number, UserQueue>();
47
+ const queues = new Map<string, UserQueue>();
46
48
 
47
- function getQueue(userId: number): UserQueue {
49
+ function getQueue(userId: string): UserQueue {
48
50
  let queue = queues.get(userId);
49
51
  if (!queue) {
50
52
  queue = { running: false, inbox: [], controller: null };
@@ -55,7 +57,7 @@ function getQueue(userId: number): UserQueue {
55
57
 
56
58
  export function stopAgentTurn(
57
59
  host: AgentHost,
58
- userId: number,
60
+ userId: string,
59
61
  ): { running: boolean; dropped: number; approvals: number } {
60
62
  const queue = queues.get(userId);
61
63
  const dropped = queue?.inbox.length ?? 0;
@@ -72,29 +74,31 @@ export function stopAgentTurn(
72
74
  function kickQueue(
73
75
  host: AgentHost,
74
76
  event: MessageEvent,
75
- userId: number,
77
+ userId: string,
76
78
  queue: UserQueue,
77
79
  ): void {
78
80
  if (queue.running) return;
79
81
  const next = queue.inbox.shift();
80
82
  if (!next) return;
81
83
  queue.running = true;
82
- void drainTurns(host, event, userId, queue, next);
84
+ void drainTurns(host, event, identityOf(event), queue, next);
83
85
  }
84
86
 
85
87
  export function runAgentTurn(
86
88
  host: AgentHost,
87
89
  event: MessageEvent,
90
+ platform: AgentPlatform,
88
91
  ): Promise<void> {
89
- const userId = Number(event.user_id || event.sender?.user_id || 0);
90
- if (!userId) return Promise.resolve();
92
+ const identity = identityOf(event);
93
+ if (!identity.userId) return Promise.resolve();
94
+ const userId = identity.scope;
91
95
  const queue = getQueue(userId);
92
96
 
93
97
  // 先占住轮次再准备输入,保证消息按到达顺序处理
94
98
  const ownsTurn = !queue.running;
95
99
  if (ownsTurn) queue.running = true;
96
100
 
97
- return prepareInput(host, event, userId)
101
+ return prepareInput(host, event, identity, platform)
98
102
  .then((input) => {
99
103
  if (!input) {
100
104
  if (ownsTurn) {
@@ -112,7 +116,7 @@ export function runAgentTurn(
112
116
  kickQueue(host, event, userId, queue);
113
117
  return;
114
118
  }
115
- return drainTurns(host, event, userId, queue, input);
119
+ return drainTurns(host, event, identity, queue, input);
116
120
  })
117
121
  .catch((err) => {
118
122
  if (ownsTurn) {
@@ -126,7 +130,7 @@ export function runAgentTurn(
126
130
  async function drainTurns(
127
131
  host: AgentHost,
128
132
  event: MessageEvent,
129
- userId: number,
133
+ identity: AgentIdentity,
130
134
  queue: UserQueue,
131
135
  first: PreparedInput,
132
136
  ): Promise<void> {
@@ -135,29 +139,31 @@ async function drainTurns(
135
139
  try {
136
140
  let next: PreparedInput | undefined = first;
137
141
  while (next) {
138
- await executeTurn(host, event, userId, next, queue, controller.signal);
142
+ await executeTurn(host, event, identity, next, queue, controller.signal);
139
143
  next = queue.inbox.shift();
140
144
  }
141
145
  } finally {
142
146
  queue.controller = null;
143
147
  queue.running = false;
144
- kickQueue(host, event, userId, queue);
148
+ kickQueue(host, event, identity.scope, queue);
145
149
  }
146
150
  }
147
151
 
148
152
  async function prepareInput(
149
153
  host: AgentHost,
150
154
  event: MessageEvent,
151
- userId: number,
155
+ identity: AgentIdentity,
156
+ platform: AgentPlatform,
152
157
  ): Promise<PreparedInput | null> {
153
158
  const resolved = host.resolveModel();
154
159
  const bot = event.bot ?? host.ctx.pickBot(event.self_id);
155
160
  const media = extractMedia(event);
156
161
  const downloads = await downloadMediaItems(
157
162
  media,
158
- host.workspaceRoot(userId),
163
+ host.workspaceRoot(identity.scope),
159
164
  {
160
165
  bot,
166
+ platform,
161
167
  },
162
168
  ).catch((err) => {
163
169
  host.logger.warn(`[agent] attachment download failed: ${err}`);
@@ -216,7 +222,7 @@ async function prepareInput(
216
222
 
217
223
  function steeringMessages(
218
224
  host: AgentHost,
219
- userId: number,
225
+ userId: string,
220
226
  queue: UserQueue,
221
227
  ): AgentChatMessage[] {
222
228
  if (queue.inbox.length === 0) return [];
@@ -249,11 +255,13 @@ function steeringContent(
249
255
  async function executeTurn(
250
256
  host: AgentHost,
251
257
  event: MessageEvent,
252
- userId: number,
258
+ identity: AgentIdentity,
253
259
  input: PreparedInput,
254
260
  queue: UserQueue,
255
261
  abortSignal: AbortSignal,
256
262
  ): Promise<void> {
263
+ const userId = identity.scope;
264
+ const sendUserId = identity.userId;
257
265
  const base = host.getBase();
258
266
  const settings = host.getSettings();
259
267
  const resolved = host.resolveModel();
@@ -263,7 +271,7 @@ async function executeTurn(
263
271
  const activity = new TurnActivity(digestMode);
264
272
 
265
273
  if (!resolved) {
266
- await replyError(host, bot, userId, "AI 服务不可用,请先在 WebUI 配置模型");
274
+ await replyError(host, bot, sendUserId, "AI 服务不可用,请先在 WebUI 配置模型");
267
275
  return;
268
276
  }
269
277
 
@@ -286,7 +294,7 @@ async function executeTurn(
286
294
 
287
295
  const sendToUser = async (text: string): Promise<void> => {
288
296
  if (!bot) return;
289
- await bot.sendMessage({ type: "private", user_id: userId }, [
297
+ await bot.sendMessage({ type: "private", user_id: sendUserId }, [
290
298
  host.ctx.segment.text(text),
291
299
  ]);
292
300
  };
@@ -332,6 +340,7 @@ async function executeTurn(
332
340
 
333
341
  const { tools, webSearchState } = buildTurnTools(host, {
334
342
  userId,
343
+ sendUserId,
335
344
  bot,
336
345
  runId,
337
346
  reporter,
@@ -390,15 +399,15 @@ async function executeTurn(
390
399
  const sender = new TurnSender(
391
400
  host,
392
401
  bot,
393
- userId,
402
+ sendUserId,
394
403
  settings.enableMarkdownScreenshot && Boolean(host.screenshot),
395
404
  );
396
405
  const usageId = `agent:${sessionRow.sessionId}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
397
406
  const usageContext = {
398
407
  usageId,
399
408
  source: "agent",
400
- botId: Number(event?.self_id) || undefined,
401
- userId,
409
+ botId: identity.botId || undefined,
410
+ userId: sendUserId,
402
411
  sessionId: sessionRow.sessionId,
403
412
  };
404
413
 
@@ -464,7 +473,7 @@ async function executeTurn(
464
473
  }
465
474
 
466
475
  // 批量模式:把本回合的全部操作合并成一条转发记录,放在最终回复之前
467
- await flushActivity(host, activity, bot, userId, event);
476
+ await flushActivity(host, activity, bot, sendUserId, event);
468
477
 
469
478
  if (settings.stream && !digestMode) {
470
479
  await sender.finishStream(finalText);
@@ -483,11 +492,11 @@ async function executeTurn(
483
492
  status = "error";
484
493
  errorText = String(err);
485
494
  host.logger.error(`[agent] turn failed: ${err}`);
486
- await flushActivity(host, activity, bot, userId, event);
495
+ await flushActivity(host, activity, bot, sendUserId, event);
487
496
  await replyError(
488
497
  host,
489
498
  bot,
490
- userId,
499
+ sendUserId,
491
500
  `Agent 处理出错:${errorText.slice(0, 300)}`,
492
501
  );
493
502
  } finally {
@@ -552,15 +561,13 @@ async function flushActivity(
552
561
  host: AgentHost,
553
562
  activity: TurnActivity,
554
563
  bot: Bot | undefined,
555
- userId: number,
564
+ userId: string,
556
565
  event: MessageEvent,
557
566
  ): Promise<void> {
558
567
  if (!bot || activity.total === 0) return;
559
568
  const target = { type: "private" as const, user_id: userId };
560
- const selfId = String(
561
- event.self_id || host.ctx.bot?.bot_id || bot.bot_id || userId,
562
- );
563
- const nickname = host.ctx.bot?.nickname ?? "Agent";
569
+ const selfId = String(event.self_id || bot.bot_id || userId);
570
+ const nickname = bot.nickname ?? "Agent";
564
571
  const nodes = activity.buildNodes(host.ctx, selfId, nickname);
565
572
  try {
566
573
  await bot.sendForward(target, nodes, activity.buildDisplay());
@@ -583,7 +590,7 @@ async function flushActivity(
583
590
  async function replyError(
584
591
  host: AgentHost,
585
592
  bot: Bot | undefined,
586
- userId: number,
593
+ userId: string,
587
594
  text: string,
588
595
  ): Promise<void> {
589
596
  if (!bot) return;
package/core/send.ts CHANGED
@@ -36,7 +36,7 @@ export class TurnSender {
36
36
  constructor(
37
37
  private host: AgentHost,
38
38
  private bot: Bot | undefined,
39
- private userId: number,
39
+ private userId: string,
40
40
  private enableScreenshot: boolean,
41
41
  ) {}
42
42
 
package/core/session.ts CHANGED
@@ -3,44 +3,44 @@ import type { AgentDatabase, AgentSessionRow } from "../db";
3
3
  export class SessionManager {
4
4
  constructor(private db: AgentDatabase) {}
5
5
 
6
- generation(userId: number): number {
6
+ generation(userId: string): number {
7
7
  return this.db.getUserGeneration(userId);
8
8
  }
9
9
 
10
- sessionId(userId: number): string {
10
+ sessionId(userId: string): string {
11
11
  return `agent:${userId}:g${this.generation(userId)}`;
12
12
  }
13
13
 
14
- get(userId: number): AgentSessionRow {
14
+ get(userId: string): AgentSessionRow {
15
15
  return this.db.getOrCreateSession(this.sessionId(userId), userId);
16
16
  }
17
17
 
18
- newSession(userId: number): AgentSessionRow {
18
+ newSession(userId: string): AgentSessionRow {
19
19
  this.db.bumpUserGenerationTo(userId, this.db.maxGeneration(userId) + 1);
20
20
  return this.get(userId);
21
21
  }
22
22
 
23
- resume(userId: number, generation: number): AgentSessionRow | undefined {
23
+ resume(userId: string, generation: number): AgentSessionRow | undefined {
24
24
  const target = this.db.getSessionByGeneration(userId, generation);
25
25
  if (!target) return undefined;
26
26
  this.db.bumpUserGenerationTo(userId, generation);
27
27
  return this.db.getOrCreateSession(target.sessionId, userId);
28
28
  }
29
29
 
30
- resumableSessions(userId: number, excludeSessionId: string): AgentSessionRow[] {
30
+ resumableSessions(userId: string, excludeSessionId: string): AgentSessionRow[] {
31
31
  return this.db
32
32
  .listSessions(userId, { archived: false })
33
33
  .filter((session) => session.sessionId !== excludeSessionId);
34
34
  }
35
35
 
36
- archive(userId: number, generation: number): AgentSessionRow | undefined {
36
+ archive(userId: string, generation: number): AgentSessionRow | undefined {
37
37
  const target = this.db.getSessionByGeneration(userId, generation);
38
38
  if (!target) return undefined;
39
39
  this.db.setSessionMeta(target.sessionId, { archived: true });
40
40
  return this.db.getSessionByGeneration(userId, generation);
41
41
  }
42
42
 
43
- reset(userId: number): void {
43
+ reset(userId: string): void {
44
44
  this.db.resetSession(this.sessionId(userId));
45
45
  }
46
46
 
@@ -48,11 +48,11 @@ export class SessionManager {
48
48
  return this.db.getMessagesAfter(session.sessionId, session.summaryUpTo);
49
49
  }
50
50
 
51
- append(userId: number, role: "user" | "assistant", content: string): number {
51
+ append(userId: string, role: "user" | "assistant", content: string): number {
52
52
  return this.db.appendMessage(this.sessionId(userId), role, content);
53
53
  }
54
54
 
55
- setEmotion(userId: number, emotion: string): void {
55
+ setEmotion(userId: string, emotion: string): void {
56
56
  this.db.setSessionMeta(this.sessionId(userId), { emotion });
57
57
  }
58
58
  }
package/db.ts CHANGED
@@ -18,6 +18,14 @@ function rowString(row: SqlRow, key: string, fallback = ""): string {
18
18
  return typeof value === "string" ? value : fallback;
19
19
  }
20
20
 
21
+ /** id 列可能是 TEXT(openid)也可能是 INTEGER 亲和存下的数字,统一转字符串 */
22
+ function rowId(row: SqlRow | null | undefined, key: string, fallback = ""): string {
23
+ const value = row?.[key];
24
+ if (value == null) return fallback;
25
+ const text = String(value);
26
+ return text.length > 0 ? text : fallback;
27
+ }
28
+
21
29
  export type SessionPlanStatus = "pending" | "in_progress" | "completed";
22
30
 
23
31
  export interface SessionPlanItem {
@@ -27,7 +35,7 @@ export interface SessionPlanItem {
27
35
 
28
36
  export interface AgentSessionRow {
29
37
  sessionId: string;
30
- userId: number;
38
+ userId: string;
31
39
  generation: number;
32
40
  emotion: string;
33
41
  summary: string;
@@ -51,7 +59,7 @@ export interface AgentMessageRow {
51
59
  export interface AgentRunRow {
52
60
  id: number;
53
61
  sessionId: string;
54
- userId: number;
62
+ userId: string;
55
63
  model: string;
56
64
  status: "ok" | "error";
57
65
  iterations: number;
@@ -93,7 +101,7 @@ export class AgentDatabase {
93
101
  this.db.run(`
94
102
  CREATE TABLE IF NOT EXISTS sessions (
95
103
  session_id TEXT PRIMARY KEY,
96
- user_id INTEGER NOT NULL,
104
+ user_id TEXT NOT NULL,
97
105
  generation INTEGER NOT NULL DEFAULT 0,
98
106
  emotion TEXT NOT NULL DEFAULT '',
99
107
  summary TEXT NOT NULL DEFAULT '',
@@ -106,7 +114,7 @@ export class AgentDatabase {
106
114
  updated_at INTEGER NOT NULL
107
115
  );
108
116
  CREATE TABLE IF NOT EXISTS user_state (
109
- user_id INTEGER PRIMARY KEY,
117
+ user_id TEXT PRIMARY KEY,
110
118
  generation INTEGER NOT NULL DEFAULT 0,
111
119
  updated_at INTEGER NOT NULL
112
120
  );
@@ -121,7 +129,7 @@ export class AgentDatabase {
121
129
  CREATE TABLE IF NOT EXISTS runs (
122
130
  id INTEGER PRIMARY KEY AUTOINCREMENT,
123
131
  session_id TEXT NOT NULL,
124
- user_id INTEGER NOT NULL,
132
+ user_id TEXT NOT NULL,
125
133
  model TEXT NOT NULL DEFAULT '',
126
134
  status TEXT NOT NULL DEFAULT 'ok',
127
135
  iterations INTEGER NOT NULL DEFAULT 0,
@@ -142,23 +150,55 @@ export class AgentDatabase {
142
150
  created_at INTEGER NOT NULL
143
151
  );
144
152
  `);
153
+ this.#widenLegacyIdColumns();
154
+ }
155
+
156
+ /**
157
+ * 旧库的 user_state.user_id 是 INTEGER PRIMARY KEY(rowid 别名),
158
+ * 写入 openid 会直接抛 SQLiteError: datatype mismatch,这里重建成 TEXT。
159
+ */
160
+ #widenLegacyIdColumns(): void {
161
+ const info = this.db
162
+ .query("PRAGMA table_info(user_state)")
163
+ .all() as Array<{ name?: string; type?: string }>;
164
+ const column = info.find((item) => item.name === "user_id");
165
+ if (!column || String(column.type ?? "").toUpperCase() === "TEXT") return;
166
+
167
+ this.db.run("BEGIN");
168
+ try {
169
+ this.db.run("ALTER TABLE user_state RENAME TO user_state_legacy");
170
+ this.db.run(`
171
+ CREATE TABLE user_state (
172
+ user_id TEXT PRIMARY KEY,
173
+ generation INTEGER NOT NULL DEFAULT 0,
174
+ updated_at INTEGER NOT NULL
175
+ );
176
+ INSERT INTO user_state (user_id, generation, updated_at)
177
+ SELECT CAST(user_id AS TEXT), generation, updated_at FROM user_state_legacy;
178
+ DROP TABLE user_state_legacy;
179
+ `);
180
+ this.db.run("COMMIT");
181
+ } catch (err) {
182
+ this.db.run("ROLLBACK");
183
+ throw err;
184
+ }
145
185
  }
146
186
 
147
- getUserGeneration(userId: number): number {
187
+ getUserGeneration(userId: string): number {
148
188
  const row = this.db
149
189
  .query("SELECT generation FROM user_state WHERE user_id = ?")
150
190
  .get(userId) as SqlRow | null;
151
191
  return rowNumber(row, "generation", 0);
152
192
  }
153
193
 
154
- maxGeneration(userId: number): number {
194
+ maxGeneration(userId: string): number {
155
195
  const row = this.db
156
196
  .query("SELECT MAX(generation) AS max FROM sessions WHERE user_id = ?")
157
197
  .get(userId) as SqlRow | null;
158
198
  return rowNumber(row, "max", -1);
159
199
  }
160
200
 
161
- bumpUserGenerationTo(userId: number, generation: number): void {
201
+ bumpUserGenerationTo(userId: string, generation: number): void {
162
202
  this.db.run(
163
203
  `INSERT INTO user_state (user_id, generation, updated_at) VALUES (?, ?, ?)
164
204
  ON CONFLICT(user_id) DO UPDATE SET generation = ?, updated_at = ?`,
@@ -166,7 +206,7 @@ export class AgentDatabase {
166
206
  );
167
207
  }
168
208
 
169
- getOrCreateSession(sessionId: string, userId: number): AgentSessionRow {
209
+ getOrCreateSession(sessionId: string, userId: string): AgentSessionRow {
170
210
  const now = Date.now();
171
211
  this.db.run(
172
212
  `INSERT INTO sessions (session_id, user_id, generation, created_at, updated_at)
@@ -181,7 +221,7 @@ export class AgentDatabase {
181
221
  }
182
222
 
183
223
  getSessionByGeneration(
184
- userId: number,
224
+ userId: string,
185
225
  generation: number,
186
226
  ): AgentSessionRow | undefined {
187
227
  const row = this.db
@@ -191,7 +231,7 @@ export class AgentDatabase {
191
231
  }
192
232
 
193
233
  listSessions(
194
- userId: number,
234
+ userId: string,
195
235
  options: { archived?: boolean } = {},
196
236
  ): AgentSessionRow[] {
197
237
  const rows = options.archived === undefined
@@ -314,7 +354,7 @@ export class AgentDatabase {
314
354
  }
315
355
 
316
356
  /** 删除该用户的全部会话(含归档)及其消息、运行记录与工具调用明细。 */
317
- clearUserSessions(userId: number): ClearSessionsResult {
357
+ clearUserSessions(userId: string): ClearSessionsResult {
318
358
  const ids = this.listSessions(userId).map((session) => session.sessionId);
319
359
  const result: ClearSessionsResult = {
320
360
  sessions: ids.length,
@@ -360,7 +400,7 @@ export class AgentDatabase {
360
400
  return rowNumber(row, "count", 0);
361
401
  }
362
402
 
363
- startRun(sessionId: string, userId: number, model: string): number {
403
+ startRun(sessionId: string, userId: string, model: string): number {
364
404
  const result = this.db
365
405
  .query(
366
406
  "INSERT INTO runs (session_id, user_id, model, started_at) VALUES (?, ?, ?, ?)",
@@ -408,7 +448,7 @@ export class AgentDatabase {
408
448
  );
409
449
  }
410
450
 
411
- getRunStats(userId: number): { runs: number; toolCalls: number } {
451
+ getRunStats(userId: string): { runs: number; toolCalls: number } {
412
452
  const runRow = this.db
413
453
  .query("SELECT COUNT(*) AS count FROM runs WHERE user_id = ?")
414
454
  .get(userId) as SqlRow | null;
@@ -449,7 +489,7 @@ function toSessionRow(row: SqlRow): AgentSessionRow {
449
489
  const sessionId = rowString(row, "session_id");
450
490
  return {
451
491
  sessionId,
452
- userId: rowNumber(row, "user_id", 0),
492
+ userId: rowId(row, "user_id"),
453
493
  generation: rowNumber(row, "generation", parseGeneration(sessionId)),
454
494
  emotion: rowString(row, "emotion"),
455
495
  summary: rowString(row, "summary"),
@@ -1,13 +1,19 @@
1
1
  import type { MessageEvent } from "mioku";
2
2
  import type { AgentHost } from "../types";
3
+ import { identityOf } from "../core/identity";
3
4
  import { runAgentTurn } from "../core/loop";
5
+ import { genericPlatform } from "../platforms/generic";
6
+ import type { AgentPlatform } from "../platforms/types";
4
7
 
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
- };
8
+ /** 各平台分支共用的入口:权限/自消息过滤后进入 agent 轮次 */
9
+ export async function handleAgentMessage(
10
+ host: AgentHost,
11
+ event: MessageEvent,
12
+ platform: AgentPlatform = genericPlatform,
13
+ ): Promise<void> {
14
+ if (event.message_type === "group") return;
15
+ const identity = identityOf(event);
16
+ if (!identity.userId || identity.userId === identity.botId) return;
17
+ if (!(await host.isAllowed(identity.userId))) return;
18
+ await runAgentTurn(host, event, platform);
13
19
  }
package/index.ts CHANGED
@@ -5,7 +5,7 @@ import { SessionManager } from "./core/session";
5
5
  import { EmotionManager } from "./core/emotion";
6
6
  import { ApprovalManager } from "./tools/approval";
7
7
  import { readChatSharedConfig } from "./core/chat-config";
8
- import { createMessageHandler } from "./handlers/message";
8
+ import { registerAgentPlatforms } from "./platforms";
9
9
  import { registerCommands } from "./commands";
10
10
  import { mergeAgentConfig } from "./utils/config";
11
11
  import { workspaceRootFor } from "./tools/perm";
@@ -23,13 +23,13 @@ import type {
23
23
  ResolvedModel,
24
24
  } from "./types";
25
25
 
26
- function normalizeIdList(input: unknown): number[] {
26
+ function normalizeIdList(input: unknown): string[] {
27
27
  if (!Array.isArray(input)) return [];
28
28
  return Array.from(
29
29
  new Set(
30
30
  input
31
- .map((item) => Math.floor(Number(item)))
32
- .filter((id) => Number.isFinite(id) && id > 0),
31
+ .map((item) => String(item ?? "").trim())
32
+ .filter((id) => id.length > 0),
33
33
  ),
34
34
  );
35
35
  }
@@ -136,18 +136,18 @@ export default definePlugin({
136
136
  getSettings: () => cachedSettings,
137
137
  getChatShared: () => readChatSharedConfig(configService),
138
138
  resolveModel,
139
- workspaceRoot: (userId: number) =>
139
+ workspaceRoot: (userId: string) =>
140
140
  workspaceRootFor(cachedBase.workspaceDir, userId),
141
- isAllowed: async (userId: number) => {
142
- const owners = (ctx.config.owners ?? []).map(Number);
143
- if (owners.includes(userId)) return true;
144
- if (
145
- cachedBase.access.allowAdmins &&
146
- (ctx.config.admins ?? []).map(Number).includes(userId)
147
- ) {
141
+ isAllowed: async (userId: string) => {
142
+ const target = String(userId ?? "").trim();
143
+ if (!target) return false;
144
+ const matches = (list: readonly unknown[] | undefined): boolean =>
145
+ (list ?? []).some((item) => String(item ?? "").trim() === target);
146
+ if (matches(ctx.config.owners)) return true;
147
+ if (cachedBase.access.allowAdmins && matches(ctx.config.admins)) {
148
148
  return true;
149
149
  }
150
- return normalizeIdList(cachedBase.access.users).includes(userId);
150
+ return normalizeIdList(cachedBase.access.users).includes(target);
151
151
  },
152
152
  updateBase: async (patch) => {
153
153
  if (configService) {
@@ -160,7 +160,7 @@ export default definePlugin({
160
160
  };
161
161
 
162
162
  registerCommands(host);
163
- ctx.handle("message", createMessageHandler(host));
163
+ const disposePlatforms = registerAgentPlatforms(ctx, host);
164
164
 
165
165
  const resolved = resolveModel();
166
166
  ctx.logger.info(
@@ -168,6 +168,7 @@ export default definePlugin({
168
168
  );
169
169
 
170
170
  return () => {
171
+ disposePlatforms();
171
172
  approvals.dispose();
172
173
  db.close();
173
174
  ctx.logger.info("agent 插件已卸载");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-plugin-agent",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "超级用户 Agent 插件",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -0,0 +1,11 @@
1
+ import type { AgentPlatform } from "./types";
2
+ import { EMPTY_FILE_LOOKUP } from "./types";
3
+
4
+ /** 未单独登记的适配器(如 stdin、未来的新平台)走的兜底分支 */
5
+ export const genericPlatform: AgentPlatform = {
6
+ adapter: "",
7
+ route: "message",
8
+ async resolveFile() {
9
+ return EMPTY_FILE_LOOKUP;
10
+ },
11
+ };
@@ -0,0 +1,47 @@
1
+ import type { AgentPlatform, PlatformFileLookup } from "./types";
2
+ import { EMPTY_FILE_LOOKUP, mergeFileLookup } from "./types";
3
+
4
+ interface IcqqFileHolder {
5
+ getFileUrl?(fileId: string): Promise<string>;
6
+ getFileInfo?(fileId: string): Promise<{ url?: string; name?: string } | null>;
7
+ }
8
+
9
+ interface IcqqFileClient {
10
+ pickGroup?(groupId: string): IcqqFileHolder;
11
+ pickFriend?(userId: string): IcqqFileHolder;
12
+ }
13
+
14
+ /** icqq:pickGroup / pickFriend 上的 getFileInfo / getFileUrl */
15
+ export const icqqPlatform: AgentPlatform = {
16
+ adapter: "icqq",
17
+ route: "icqq:message",
18
+ async resolveFile(bot, ref): Promise<PlatformFileLookup> {
19
+ const client = bot.as<IcqqFileClient>();
20
+ const holder = ref.groupId
21
+ ? client.pickGroup?.(ref.groupId)
22
+ : ref.userId
23
+ ? client.pickFriend?.(ref.userId)
24
+ : undefined;
25
+ if (!holder) return EMPTY_FILE_LOOKUP;
26
+
27
+ const found: PlatformFileLookup = { sources: [], names: [] };
28
+ if (holder.getFileInfo) {
29
+ try {
30
+ mergeFileLookup(found, await holder.getFileInfo(ref.fileId));
31
+ } catch {
32
+ // 文件不存在或无权限时继续尝试 getFileUrl
33
+ }
34
+ }
35
+ if (found.sources.length === 0 && holder.getFileUrl) {
36
+ try {
37
+ const url = await holder.getFileUrl(ref.fileId);
38
+ if (typeof url === "string" && url.trim()) {
39
+ found.sources.push(url.trim());
40
+ }
41
+ } catch {
42
+ // 调用方会回退到消息段里自带的 url/file
43
+ }
44
+ }
45
+ return found;
46
+ },
47
+ };
@@ -0,0 +1,49 @@
1
+ import type { MessageEvent, MiokuContext } from "mioku";
2
+ import type { AgentHost } from "../types";
3
+ import { handleAgentMessage } from "../handlers/message";
4
+ import { genericPlatform } from "./generic";
5
+ import { icqqPlatform } from "./icqq";
6
+ import { onebotv11Platform } from "./onebotv11";
7
+ import { qqOfficialPlatform } from "./qq-official";
8
+ import type { AgentPlatform } from "./types";
9
+
10
+ /** 每个平台一个分支文件,新增平台在这里登记即可 */
11
+ export const AGENT_PLATFORMS: readonly AgentPlatform[] = [
12
+ onebotv11Platform,
13
+ icqqPlatform,
14
+ qqOfficialPlatform,
15
+ ];
16
+
17
+ /**
18
+ * 按适配器前缀路由注册平台分支:
19
+ * `onebotv11:message` / `icqq:message` / `qq-official:message` 各管自己的差异,
20
+ * 未登记的适配器由通用 `message` 分支兜底。
21
+ */
22
+ export function registerAgentPlatforms(
23
+ ctx: MiokuContext,
24
+ host: AgentHost,
25
+ ): () => void {
26
+ const disposers: Array<() => void> = [];
27
+
28
+ for (const platform of AGENT_PLATFORMS) {
29
+ disposers.push(
30
+ ctx.handle(platform.route, (event) =>
31
+ handleAgentMessage(host, event as unknown as MessageEvent, platform),
32
+ ),
33
+ );
34
+ }
35
+
36
+ const owned = new Set(AGENT_PLATFORMS.map((platform) => platform.adapter));
37
+ disposers.push(
38
+ ctx.handle("message", (event) => {
39
+ const messageEvent = event as unknown as MessageEvent;
40
+ const adapter = String(messageEvent?.bot?.adapter ?? "");
41
+ if (owned.has(adapter)) return;
42
+ return handleAgentMessage(host, messageEvent, genericPlatform);
43
+ }),
44
+ );
45
+
46
+ return () => {
47
+ for (const dispose of disposers) dispose();
48
+ };
49
+ }
@@ -0,0 +1,40 @@
1
+ import type { AgentPlatform, PlatformFileLookup } from "./types";
2
+ import { EMPTY_FILE_LOOKUP, mergeFileLookup } from "./types";
3
+
4
+ /** onebotv11:get_file / get_group_file_url / get_private_file_url */
5
+ export const onebotv11Platform: AgentPlatform = {
6
+ adapter: "onebotv11",
7
+ route: "onebotv11:message",
8
+ async resolveFile(bot, ref): Promise<PlatformFileLookup> {
9
+ const attempts: Array<[string, Record<string, unknown>]> = [
10
+ ["get_file", { file_id: ref.fileId }],
11
+ ];
12
+ if (ref.groupId) {
13
+ attempts.push([
14
+ "get_group_file_url",
15
+ { group_id: ref.groupId, file_id: ref.fileId },
16
+ ]);
17
+ }
18
+ if (ref.userId) {
19
+ attempts.push([
20
+ "get_private_file_url",
21
+ { user_id: ref.userId, file_id: ref.fileId },
22
+ ]);
23
+ }
24
+ const found: PlatformFileLookup = { sources: [], names: [] };
25
+ for (const [action, params] of attempts) {
26
+ try {
27
+ mergeFileLookup(
28
+ found,
29
+ await bot.sendApi<Record<string, unknown>>(action, params),
30
+ );
31
+ } catch {
32
+ // 协议端不支持该 action 时继续尝试下一个
33
+ }
34
+ if (found.sources.length > 0) break;
35
+ }
36
+ return found;
37
+ },
38
+ };
39
+
40
+ export { EMPTY_FILE_LOOKUP };
@@ -0,0 +1,14 @@
1
+ import type { AgentPlatform } from "./types";
2
+ import { EMPTY_FILE_LOOKUP } from "./types";
3
+
4
+ /**
5
+ * QQ 官方通道:附件事件自带公网 URL(见 adapter 的 segments.ts),
6
+ * 官方没有 file_id 换下载地址的公开接口,所以这里不做事。
7
+ */
8
+ export const qqOfficialPlatform: AgentPlatform = {
9
+ adapter: "qq-official",
10
+ route: "qq-official:message",
11
+ async resolveFile() {
12
+ return EMPTY_FILE_LOOKUP;
13
+ },
14
+ };
@@ -0,0 +1,53 @@
1
+ import type { Bot } from "mioku";
2
+
3
+ /** 需要按平台换取下载地址的附件引用 */
4
+ export interface PlatformFileRef {
5
+ fileId: string;
6
+ groupId?: string;
7
+ userId?: string;
8
+ }
9
+
10
+ export interface PlatformFileLookup {
11
+ sources: string[];
12
+ names: string[];
13
+ }
14
+
15
+ export const EMPTY_FILE_LOOKUP: PlatformFileLookup = { sources: [], names: [] };
16
+
17
+ /** 把平台返回的原始结果合并成下载来源(各平台实现共用) */
18
+ export const mergeFileLookup = (
19
+ target: PlatformFileLookup,
20
+ result: unknown,
21
+ ): void => {
22
+ if (!result || typeof result !== "object") return;
23
+ const record = result as Record<string, unknown>;
24
+ for (const key of ["url", "file", "path"]) {
25
+ const value = record[key];
26
+ if (typeof value === "string" && value.trim()) {
27
+ target.sources.push(value.trim());
28
+ }
29
+ }
30
+ const base64 = record.base64 ?? record.data;
31
+ if (typeof base64 === "string" && base64.trim()) {
32
+ target.sources.push(`base64://${base64.trim()}`);
33
+ }
34
+ for (const key of ["file_name", "name"]) {
35
+ const value = record[key];
36
+ if (typeof value === "string" && value.trim()) {
37
+ target.names.push(value.trim());
38
+ }
39
+ }
40
+ };
41
+
42
+ /**
43
+ * 一个平台分支:自己订阅 `adapter:message` 路由,自己处理平台专有差异。
44
+ * 新增平台只要加一个文件并登记到 `platforms/index.ts`。
45
+ */
46
+ export interface AgentPlatform {
47
+ /** 适配器名,与 `bot.adapter` 对应 */
48
+ readonly adapter: string;
49
+ /** 该分支订阅的事件路由,如 `onebotv11:message` */
50
+ readonly route: string;
51
+ /** 平台专有的 file_id → 下载来源;没有该能力时返回空 */
52
+ resolveFile(bot: Bot, ref: PlatformFileRef): Promise<PlatformFileLookup>;
53
+ }
package/tools/approval.ts CHANGED
@@ -2,7 +2,7 @@ import type { AgentPermissionLevel } from "../types";
2
2
 
3
3
  export interface PendingApproval {
4
4
  id: string;
5
- userId: number;
5
+ userId: string;
6
6
  command: string;
7
7
  cwd: string;
8
8
  level: AgentPermissionLevel;
@@ -18,7 +18,7 @@ interface PendingEntry extends PendingApproval {
18
18
 
19
19
  export class ApprovalManager {
20
20
  private pending = new Map<string, PendingEntry>();
21
- private latestByUser = new Map<number, string>();
21
+ private latestByUser = new Map<string, string>();
22
22
  private seq = 0;
23
23
 
24
24
  create(
@@ -55,7 +55,7 @@ export class ApprovalManager {
55
55
  return true;
56
56
  }
57
57
 
58
- resolveLatest(userId: number, approved: boolean): PendingApproval | null {
58
+ resolveLatest(userId: string, approved: boolean): PendingApproval | null {
59
59
  const id = this.latestByUser.get(userId) ?? this.latestFor(userId);
60
60
  if (!id) return null;
61
61
  const entry = this.pending.get(id);
@@ -65,13 +65,13 @@ export class ApprovalManager {
65
65
  return approval;
66
66
  }
67
67
 
68
- latestByUserId(userId: number): PendingApproval | null {
68
+ latestByUserId(userId: string): PendingApproval | null {
69
69
  const id = this.latestByUser.get(userId) ?? this.latestFor(userId);
70
70
  const entry = id ? this.pending.get(id) : undefined;
71
71
  return entry ? this.toPending(entry) : null;
72
72
  }
73
73
 
74
- cancelByUser(userId: number): number {
74
+ cancelByUser(userId: string): number {
75
75
  const ids = [...this.pending.values()]
76
76
  .filter((entry) => entry.userId === userId)
77
77
  .map((entry) => entry.id);
@@ -79,7 +79,7 @@ export class ApprovalManager {
79
79
  return ids.length;
80
80
  }
81
81
 
82
- listByUser(userId: number): PendingApproval[] {
82
+ listByUser(userId: string): PendingApproval[] {
83
83
  return [...this.pending.values()]
84
84
  .filter((entry) => entry.userId === userId)
85
85
  .sort((a, b) => a.createdAt - b.createdAt)
@@ -95,7 +95,7 @@ export class ApprovalManager {
95
95
  this.latestByUser.clear();
96
96
  }
97
97
 
98
- private latestFor(userId: number): string | undefined {
98
+ private latestFor(userId: string): string | undefined {
99
99
  let latest: PendingEntry | undefined;
100
100
  for (const entry of this.pending.values()) {
101
101
  if (entry.userId !== userId) continue;
package/tools/bash.ts CHANGED
@@ -47,7 +47,7 @@ export interface BashReporter {
47
47
  }
48
48
 
49
49
  interface BashToolDeps {
50
- userId: number;
50
+ userId: string;
51
51
  policy: FsPolicy;
52
52
  config: BashConfig;
53
53
  approvals: ApprovalManager;
package/tools/deliver.ts CHANGED
@@ -8,7 +8,7 @@ import { sendImageSource, sendLocalFile } from "../core/attachment";
8
8
  interface DeliverToolDeps {
9
9
  ctx: MiokuContext;
10
10
  bot: Bot | undefined;
11
- userId: number;
11
+ userId: string;
12
12
  policy: FsPolicy;
13
13
  }
14
14
 
package/tools/index.ts CHANGED
@@ -25,7 +25,10 @@ import { assessCommandRisk } from "../core/risk";
25
25
  import type { TurnActivity } from "../core/activity";
26
26
 
27
27
  export interface TurnToolOptions {
28
- userId: number;
28
+ /** 会话/工作区隔离键(适配器 + 平台用户 id) */
29
+ userId: string;
30
+ /** 平台原始用户 id,用于发送消息 */
31
+ sendUserId: string;
29
32
  bot: Bot | undefined;
30
33
  runId: number;
31
34
  reporter: BashReporter;
@@ -120,13 +123,13 @@ export function buildTurnTools(
120
123
  createSendFileTool({
121
124
  ctx: host.ctx,
122
125
  bot: options.bot,
123
- userId: options.userId,
126
+ userId: options.sendUserId,
124
127
  policy,
125
128
  }),
126
129
  createSendImageTool({
127
130
  ctx: host.ctx,
128
131
  bot: options.bot,
129
- userId: options.userId,
132
+ userId: options.sendUserId,
130
133
  policy,
131
134
  }),
132
135
  ];
@@ -175,6 +178,7 @@ export function buildTurnTools(
175
178
  createTodoTool({
176
179
  host,
177
180
  userId: options.userId,
181
+ sendUserId: options.sendUserId,
178
182
  bot: options.bot,
179
183
  quiet: isQuietMode(policy.level),
180
184
  }),
package/tools/perm.ts CHANGED
@@ -19,12 +19,16 @@ export function normalizePermissionLevel(value: unknown): AgentPermissionLevel {
19
19
  return "workspace-write";
20
20
  }
21
21
 
22
+ /** 作用域键里可能含 `:`(适配器前缀),统一替换成目录安全字符 */
23
+ const sanitizeSegment = (value: string): string =>
24
+ String(value ?? "").replace(/[^A-Za-z0-9._-]+/g, "_") || "unknown";
25
+
22
26
  export interface FsPolicy {
23
27
  level: AgentPermissionLevel;
24
28
  workspaceRoot: string;
25
29
  }
26
30
 
27
- export function workspaceRootFor(baseDir: string, userId: number): string {
31
+ export function workspaceRootFor(baseDir: string, userId: string): string {
28
32
  const raw = String(baseDir ?? "").trim();
29
33
  const base =
30
34
  raw && path.isAbsolute(raw)
@@ -33,7 +37,7 @@ export function workspaceRootFor(baseDir: string, userId: number): string {
33
37
  process.cwd(),
34
38
  raw || path.join("data", "agent", "workspace"),
35
39
  );
36
- return path.resolve(base, String(userId));
40
+ return path.resolve(base, sanitizeSegment(userId));
37
41
  }
38
42
 
39
43
  export function resolveWorkspacePath(policy: FsPolicy, target: string): string {
package/tools/todo.ts CHANGED
@@ -38,12 +38,15 @@ export function formatPlanText(items: SessionPlanItem[]): string {
38
38
 
39
39
  export function createTodoTool(options: {
40
40
  host: AgentHost;
41
- userId: number;
41
+ /** 会话隔离键 */
42
+ userId: string;
43
+ /** 平台原始用户 id,用于推送 */
44
+ sendUserId: string;
42
45
  bot: Bot | undefined;
43
46
  /** yolo 模式:不推送清单,用户只看最终回复 */
44
47
  quiet?: boolean;
45
48
  }): AITool {
46
- const { host, userId, bot, quiet } = options;
49
+ const { host, userId, sendUserId, bot, quiet } = options;
47
50
  return {
48
51
  name: "todo_write",
49
52
  description: DESCRIPTION,
@@ -114,7 +117,7 @@ export function createTodoTool(options: {
114
117
  if (bot && !quiet) {
115
118
  await bot
116
119
  .sendMessage(
117
- { type: "private", user_id: userId },
120
+ { type: "private", user_id: sendUserId },
118
121
  [host.ctx.segment.text(formatPlanText(items))],
119
122
  )
120
123
  .catch((err) =>
package/types.ts CHANGED
@@ -19,7 +19,7 @@ export type AgentPermissionLevel =
19
19
 
20
20
  export interface AgentAccessConfig {
21
21
  allowAdmins: boolean;
22
- users: number[];
22
+ users: string[];
23
23
  }
24
24
 
25
25
  export interface AgentBaseConfig {
@@ -104,8 +104,8 @@ export interface AgentHost {
104
104
  getSettings(): AgentSettingsConfig;
105
105
  getChatShared(): Promise<ChatSharedConfig>;
106
106
  resolveModel(): ResolvedModel | null;
107
- workspaceRoot(userId: number): string;
108
- isAllowed(userId: number): Promise<boolean>;
107
+ workspaceRoot(userId: string): string;
108
+ isAllowed(userId: string): Promise<boolean>;
109
109
  updateBase(patch: Partial<AgentBaseConfig>): Promise<void>;
110
110
  logger: MiokuContext["logger"];
111
111
  }