feat(pi): 交互式 pi 会话接入邮件工具(send_mail/read_inbox 等 10 个)

问题(⑧):守护进程用 noExtensions:true 起会话,它的邮件工具只给模型在邮件
会话里用;人在 TUI 里敲的 pi 拿不到。结果是平台的建设者自己收不到邮件 ——
一个「邮件驱动」的平台,维护者只能绕到 curl + 密钥直连 Gateway 才能看收件箱。

新增 plugins/pi-mail-bridge/extension/index.ts:把同一套工具(createMailTools)
注册到交互式会话。两者是同一条 AgentMail 身份(agent pi)的两个入口,与 DSH 的
「TUI + 邮箱是同一个 Agent」一致。

密钥解析顺序(交互式 pi 的环境里没有 AGENTMAIL_*):
  1. 进程环境
  2. AGENTMAIL_ENV_FILE(默认 /etc/agentmail/pi.env)—— 与守护进程同一把密钥,
     因此身份一致
  3. AGENTMAIL_CONFIG_DIR/agent.key 或 ~/.agentmail/agent.key
     (兼容 key 与 key_token 两种字段名;实测本机文件用的是 key_token,
      只认 key 会静默读不到)
拿不到密钥时不注册任何工具并明确告知 —— 挂一组永远 401 的工具比没有更糟。

不注册 connect_to_server:它会重写 Gateway 坐标并重新登记密钥,而交互式会话与
守护进程共用同一身份,一次 TUI 对话不该改到守护进程的配置。

为什么不会重复注册(读 SDK 实现确认,并用探针实测):
  resource-loader.js 里 noExtensions 为真时只用 cliEnabledExtensions,
  settings.json 的 extensions 数组被排除 —— 即 noExtensions:true 只加载
  命令行 -e 传入的扩展。
  探针:noExtensions=true → 扩展数=0;false → 16 个且含 pi-mail-bridge。

deploy/install.sh 增加幂等的扩展注册步骤(写入 settings.json 的 extensions)。

验证:headless pi 实际调用 read_inbox 返回真实邮件主题;工具清单含
send_mail/read_inbox/read_mail/forward_mail/upload_attachment/download_attachment/
suggest_address/list_contacts/session_participants/read_thread(10 个),
connect_to_server 按设计排除。
This commit is contained in:
2026-09-11 11:28:27 +08:00
parent 1692c615c6
commit 19a3161ee4
22 changed files with 6889 additions and 5566 deletions

View File

@ -1,3 +1,9 @@
export declare function dshSessionIdForMail(mailSessionID: any): string;
export declare function matchesMailSession(dshSessionId: any, mailSessionID: any): boolean;
export declare function pickMailSession(dshSessionIds: any[] | undefined, mailSessionID: any): string | undefined;
export declare function matchesMailSession(
dshSessionId: any,
mailSessionID: any,
): boolean;
export declare function pickMailSession(
dshSessionIds: any[] | undefined,
mailSessionID: any,
): string | undefined;

View File

@ -24,7 +24,7 @@
/** 首次尝试使用的 DSH 会话 id。 */
export function dshSessionIdForMail(mailSessionID) {
return `mail-${String(mailSessionID ?? '')}`;
return `mail-${String(mailSessionID ?? "")}`;
}
/**
@ -35,12 +35,12 @@ export function dshSessionIdForMail(mailSessionID) {
* @returns {boolean}
*/
export function matchesMailSession(dshSessionId, mailSessionID) {
const id = String(dshSessionId ?? '');
const base = dshSessionIdForMail(mailSessionID);
if (id === base) return true;
// 模型降级重试mail-<id>-r1 / -r2 / …
const suffix = id.startsWith(`${base}-r`) ? id.slice(base.length + 2) : '';
return suffix.length > 0 && /^\d+$/.test(suffix);
const id = String(dshSessionId ?? "");
const base = dshSessionIdForMail(mailSessionID);
if (id === base) return true;
// 模型降级重试mail-<id>-r1 / -r2 / …
const suffix = id.startsWith(`${base}-r`) ? id.slice(base.length + 2) : "";
return suffix.length > 0 && /^\d+$/.test(suffix);
}
/**
@ -54,19 +54,19 @@ export function matchesMailSession(dshSessionId, mailSessionID) {
* @returns {string|undefined}
*/
export function pickMailSession(dshSessionIds, mailSessionID) {
const base = dshSessionIdForMail(mailSessionID);
const list = Array.isArray(dshSessionIds) ? dshSessionIds.map(String) : [];
if (list.includes(base)) return base;
const base = dshSessionIdForMail(mailSessionID);
const list = Array.isArray(dshSessionIds) ? dshSessionIds.map(String) : [];
if (list.includes(base)) return base;
let best;
let bestIndex = Infinity;
for (const id of list) {
if (!matchesMailSession(id, mailSessionID)) continue;
const idx = Number(id.slice(base.length + 2));
if (idx < bestIndex) {
bestIndex = idx;
best = id;
}
}
return best;
let best;
let bestIndex = Infinity;
for (const id of list) {
if (!matchesMailSession(id, mailSessionID)) continue;
const idx = Number(id.slice(base.length + 2));
if (idx < bestIndex) {
bestIndex = idx;
best = id;
}
}
return best;
}

View File

@ -1,17 +1,17 @@
export interface SseClientOptions {
authHeaders: () => Record<string, string>;
baseURL: string;
path: string;
onEvent: (evt: string, data: any) => void;
log?: (msg: string) => void;
authHeaders: () => Record<string, string>;
baseURL: string;
path: string;
onEvent: (evt: string, data: any) => void;
log?: (msg: string) => void;
}
export declare function createSSEClient(options: SseClientOptions): {
stop: () => void;
reset: () => void;
stop: () => void;
reset: () => void;
};
export declare function createFrameParser(): {
push: (chunk: string) => Array<{ event: string; data: string; id: string }>;
reset: () => void;
lastEventId: () => string;
setLastEventId: (id: string) => void;
push: (chunk: string) => Array<{ event: string; data: string; id: string }>;
reset: () => void;
lastEventId: () => string;
setLastEventId: (id: string) => void;
};

View File

@ -2,12 +2,20 @@ export declare function hasOptions(question: any): boolean;
export declare function optionLabels(question: any): string[];
export declare function questionTitle(question: any): string;
export declare function flattenQuestions(questions: any[]): {
question: string;
options: string[];
context: string;
multiSelect: boolean;
question: string;
options: string[];
context: string;
multiSelect: boolean;
};
export declare function answersFromDecision(questions: any[], decision: string, note?: string): {
answers: Array<{ id: string; selected: string[]; custom?: string }>;
export declare function answersFromDecision(
questions: any[],
decision: string,
note?: string,
): {
answers: Array<{ id: string; selected: string[]; custom?: string }>;
};
export declare function isBlankAnswer(questions: any[], decision: string, note?: string): boolean;
export declare function isBlankAnswer(
questions: any[],
decision: string,
note?: string,
): boolean;