feat: agent 邮件寻址能力全面补齐 + .new 别名替换
## 别名替换(让 .new 邮件可寻址)
repo/autoalias.go: AutoAliasFor + EnsureSessionAlias
- .new 建完会话立刻给别名(形如 dsh-重构导入路径)
- 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么
- sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 -
- 撞名追加 -2/-3,全占用退 session-<uuid前8位>
- 不复用 SyncSessionAlias:那个假定已存在且跳过 manual
- 条件写入 WHERE alias IS NULL OR '',并发安全
- resolveTarget 的 .new 与默认会话两条路径都调
notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本):
- session_alias / reply_address / self_address
- 别名为空时退回省略 session 位,绝不写 new
FormatAddress(name,path,session) 空 path 也必须留 @ 与 .
## Agent 侧寻址发现(五个只读端点)
handler/agent_discovery.go:
- /agent/contacts + /agent/contacts/suggest(三段式补全)
- /agent/mail/{id} + /agent/mail/{id}/thread
- /agent/sessions/{id}/participants
- 不复用人类路由:scope 不同、审计需求不同
- 一律只读:归档/改名/权限决策仍只有人能做
repo/participants.go: SessionParticipants 逐封扫 from/to/cc
- Roles 用集合、MailCount 只数发信(0=还没开口的人)
- 发件人 path 不取 from_workspace(那列存的是 Agent 名)
repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序)
+ agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空
## 共用模块(三插件逐字节相同)
lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail
lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/
renderParticipants/renderContacts/renderThread
lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段
- selfName 参数(兼容旧调用不传的情况)
check-shared-libs.sh 纳入 addressing + discovery
## 插件侧
opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail
dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart
pi: 同上(createMailTools 加 agentName 参数)
dsh: ctx.agents.create id collision 改为 readSession 探测后 resume
dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl)
## 测试
repo: autoalias_test.go 11 + participants_test.go 7 = 18 例
plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例
go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿
端到端验证:admin 发 dsh@....new 抄送 opencode@....new
→ dsh 用 session_participants 取到地址 → send_mail 给 opencode
→ 地址取自工具返回值(.crisp-planet),未手工拼写
This commit is contained in:
235
plugins/pi-mail-bridge/src/gateway.mjs
Normal file
235
plugins/pi-mail-bridge/src/gateway.mjs
Normal file
@ -0,0 +1,235 @@
|
||||
/**
|
||||
* AgentMail Gateway 客户端 —— HTTP + SSE。
|
||||
*
|
||||
* 与另两个插件同构(同样的认证头、同样的手写 SSE 解析),区别只在这里是
|
||||
* 独立守护进程,所以密钥解析与 Last-Event-ID 的状态都归它自己管。
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail');
|
||||
const KEY_FILE = join(CONFIG_DIR, 'agent.key');
|
||||
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
||||
|
||||
/** 读取本地密钥文件;不存在或损坏时返回 null。 */
|
||||
export function readLocalKey() {
|
||||
try {
|
||||
if (!existsSync(KEY_FILE)) return null;
|
||||
const raw = JSON.parse(readFileSync(KEY_FILE, 'utf8'));
|
||||
return typeof raw?.key_token === 'string' && raw.key_token ? raw.key_token : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次安装时本地生成密钥并落盘(0600),**并把全文打印到日志**(B-1.1)。
|
||||
*
|
||||
* 打印是必须的:密钥要管理员在后台登记之后才能接入,不打印就没人知道登记什么。
|
||||
* 走 console.error 而不是任何结构化日志 —— 它一定进 journalctl(契约 9.8)。
|
||||
*/
|
||||
export function generateLocalKey(log = console.error) {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(
|
||||
KEY_FILE,
|
||||
JSON.stringify({ key_token: token, created_at: new Date().toISOString() }, null, 2),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
// 调用方传进来的 log 已经带 [pi-mail-bridge] 前缀,这里不再自己加
|
||||
log(`已在 ${KEY_FILE} 生成本地密钥。`);
|
||||
log(`该密钥需管理员在 AgentMail 后台登记后才能接入:`);
|
||||
log(` ${token}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 把 gateway 地址与身份记到 config.json,便于换机时人工核对。 */
|
||||
export function saveConfig(extra) {
|
||||
try {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
let cur = {};
|
||||
if (existsSync(CONFIG_FILE)) {
|
||||
try { cur = JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { /* 损坏就重写 */ }
|
||||
}
|
||||
writeFileSync(CONFIG_FILE, JSON.stringify({ ...cur, ...extra }, null, 2), { mode: 0o600 });
|
||||
} catch (e) {
|
||||
console.error('[pi-mail-bridge] 写 config.json 失败:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
export class GatewayClient {
|
||||
/**
|
||||
* @param {{url: string, agentName: string, agentKey: string, agentSecret: string}} opts
|
||||
*/
|
||||
constructor({ url, agentName, agentKey, agentSecret }) {
|
||||
this.baseURL = String(url || 'http://127.0.0.1:8180').replace(/\/+$/, '');
|
||||
this.agentName = agentName;
|
||||
this.agentKey = agentKey || '';
|
||||
this.agentSecret = agentSecret || '';
|
||||
this.sseAbort = null;
|
||||
// SSE 重连时带上,首次连接**不带**(B-1.4 / N-11):
|
||||
// 带上会收到一批已处理过的旧事件,插件重启一次就把历史邮件重投一遍。
|
||||
this.lastEventID = '';
|
||||
}
|
||||
|
||||
/** 认证头:有密钥走 Bearer,否则退回 name/secret。 */
|
||||
authHeaders() {
|
||||
if (this.agentKey) {
|
||||
return { Authorization: `Bearer ${this.agentKey}`, 'X-Agent-Name': this.agentName };
|
||||
}
|
||||
return { 'X-Agent-Name': this.agentName, 'X-Agent-Secret': this.agentSecret };
|
||||
}
|
||||
|
||||
async get(path) {
|
||||
const res = await fetch(`${this.baseURL}/api/v1${path}`, { headers: this.authHeaders() });
|
||||
if (!res.ok) throw new Error(`GET ${path} 失败: HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async post(path, body) {
|
||||
const res = await fetch(`${this.baseURL}/api/v1${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const err = new Error(data?.error || `POST ${path} 失败: HTTP ${res.status}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 注册。workspaces 传 [](B-1.2)—— 工作目录由每封邮件的 to_workspace 决定。 */
|
||||
async register() {
|
||||
return this.post('/agent/register', {
|
||||
name: this.agentName,
|
||||
secret: this.agentSecret || '',
|
||||
workspaces: [],
|
||||
platform: 'pi',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传附件。
|
||||
*
|
||||
* 必须走 multipart 的 `file` 字段:服务端是 `r.FormFile("file")`,
|
||||
* 且**不认 `X-Filename` 头**(grep 过 handler/attachments.go,没有这个分支)。
|
||||
* 直接 POST 二进制体会得到 400「缺少 file 字段」。
|
||||
*
|
||||
* 不手动设 Content-Type:让 undici 按 FormData 自己生成 boundary。
|
||||
*/
|
||||
async uploadFile(buf, filename) {
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([buf]), filename);
|
||||
const res = await fetch(`${this.baseURL}/api/v1/attachments`, {
|
||||
method: 'POST',
|
||||
headers: this.authHeaders(),
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.error || `上传失败: HTTP ${res.status}`);
|
||||
return data.attachment;
|
||||
}
|
||||
|
||||
async downloadFile(attachmentID) {
|
||||
const res = await fetch(`${this.baseURL}/api/v1/attachments/${attachmentID}`, {
|
||||
headers: this.authHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 SSE 长连并自动重连。
|
||||
*
|
||||
* 手写解析而不用 EventSource:Node 内建的那个不支持自定义请求头,
|
||||
* 而认证头是必须的。协议这一小块(`id:` / `event:` / `data:` + 空行分隔)
|
||||
* 比引一个依赖划算。
|
||||
*
|
||||
* 断线重连带 `Last-Event-ID`(D-7.2):服务端有 per-agent 环形缓冲,
|
||||
* 能把断连期间的事件回放出来 —— 否则那段时间的邮件只能等下次重启补拉。
|
||||
*/
|
||||
startSSE(onEvent, log = console.error) {
|
||||
this.sseAbort?.abort();
|
||||
this.sseAbort = new AbortController();
|
||||
const signal = this.sseAbort.signal;
|
||||
|
||||
const reconnect = (delay) => {
|
||||
if (signal.aborted) return;
|
||||
setTimeout(() => this.#connect(onEvent, reconnect, log), delay);
|
||||
};
|
||||
this.#connect(onEvent, reconnect, log);
|
||||
}
|
||||
|
||||
#connect(onEvent, reconnect, log) {
|
||||
const signal = this.sseAbort?.signal;
|
||||
if (!signal || signal.aborted) return;
|
||||
|
||||
const headers = { ...this.authHeaders(), Accept: 'text/event-stream' };
|
||||
// 重连时带上断点(D-7.2)。**首次连接必须不带**(N-11):那会让服务端
|
||||
// 把缓冲区里的旧事件全回放一遍,插件重启后重复处理一批已处理的邮件。
|
||||
// 只有 lastEventID 非空(= 已经收过事件)时才是重连。
|
||||
if (this.lastEventID) {
|
||||
headers['Last-Event-ID'] = this.lastEventID;
|
||||
log(`SSE 重连,从事件 ${this.lastEventID} 之后续传`);
|
||||
}
|
||||
|
||||
fetch(`${this.baseURL}/api/v1/events/stream`, { headers, signal })
|
||||
.then((res) => {
|
||||
if (!res.ok || !res.body) {
|
||||
log(`SSE 建连失败: HTTP ${res.status}`);
|
||||
return reconnect(5000);
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
let id = '';
|
||||
let evt = '';
|
||||
let data = '';
|
||||
|
||||
const read = () => {
|
||||
reader.read().then(({ done, value }) => {
|
||||
if (done) return reconnect(3000);
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('id: ')) id = line.slice(4).trim();
|
||||
else if (line.startsWith('event: ')) evt = line.slice(7).trim();
|
||||
else if (line.startsWith('data: ')) data = line.slice(6);
|
||||
else if (line === '' && evt) {
|
||||
// 事件 id 要在**分发之前**记下:分发里抛异常也不该让它丢,
|
||||
// 否则重连会从更早的位置回放,已处理的邮件再来一遍。
|
||||
if (id) this.lastEventID = id;
|
||||
try { onEvent(evt, JSON.parse(data)); } catch (e) {
|
||||
log(`SSE 事件处理失败: ${e?.message || e}`);
|
||||
}
|
||||
id = ''; evt = ''; data = '';
|
||||
}
|
||||
}
|
||||
read();
|
||||
}).catch((e) => {
|
||||
if (signal.aborted) return;
|
||||
log(`SSE 读取中断: ${e?.message || e}`);
|
||||
reconnect(5000);
|
||||
});
|
||||
};
|
||||
read();
|
||||
})
|
||||
.catch((e) => {
|
||||
if (signal.aborted) return;
|
||||
log(`SSE 连接错误: ${e?.message || e}`);
|
||||
reconnect(5000);
|
||||
});
|
||||
}
|
||||
|
||||
stopSSE() {
|
||||
this.sseAbort?.abort();
|
||||
this.sseAbort = null;
|
||||
}
|
||||
}
|
||||
693
plugins/pi-mail-bridge/src/index.mjs
Normal file
693
plugins/pi-mail-bridge/src/index.mjs
Normal file
@ -0,0 +1,693 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* AgentMail ↔ pi 桥(pi-mail-bridge)
|
||||
*
|
||||
* 形态是**常驻守护进程**,不是 pi 扩展。原因见 src/session-pool.mjs 顶部:
|
||||
* 扩展被加载进一条已存在的会话,cwd 由启动 pi 的人决定;而 B-3.1 要求每封邮件的
|
||||
* to_workspace 成为会话 cwd。桥用 SDK 的 createAgentSession 按邮件起会话,
|
||||
* 一个进程里并存多条不同 cwd 的会话(实测可行)。
|
||||
*
|
||||
* 契约实现对照(docs/PLUGIN-CONTRACT.md):
|
||||
* B-1 启动 → main()
|
||||
* B-2 心跳 → beat(),30 秒
|
||||
* B-3 new_mail → deliverMail()
|
||||
* B-4 决策 → handlePermissionDecision()
|
||||
* B-5 转发 → relaySummary(),挂在 agent_end 上
|
||||
* B-6 失败回信 → deliverMail() 末尾的 renderFailureReport
|
||||
* B-7 补拉 → catchUp()
|
||||
* B-8 权限 → permissionExtension() 的 tool_call 钩子
|
||||
* B-9 关停 → shutdown()
|
||||
*/
|
||||
|
||||
import { mkdirSync, openSync, closeSync, unlinkSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { ModelRuntime } from '@earendil-works/pi-coding-agent';
|
||||
|
||||
import { GatewayClient, readLocalKey, generateLocalKey, saveConfig } from './gateway.mjs';
|
||||
import { createMailTools } from './tools.mjs';
|
||||
import { openSession, runTurn } from './session-pool.mjs';
|
||||
import { buildMailPrompt, lastAssistantText, replySubject, relayKeyFor, describeError } from './turn.mjs';
|
||||
import { planNamingSync, planWriteBack } from './naming.mjs';
|
||||
import { resolveWorkspaceCwd, ensureCwd } from '../lib/workspace.js';
|
||||
import { modelAttemptOrder, renderFailureReport, snapshotPiModels } from '../lib/model-scope.js';
|
||||
import { snapshotPiSessions } from '../lib/session-snapshot.js';
|
||||
import { selectCatchup } from '../lib/catchup.js';
|
||||
import { explicitSends, shouldSkipAutoRelay } from '../lib/relay-dedup.js';
|
||||
|
||||
// ─── 配置 ───
|
||||
|
||||
const GATEWAY_URL = process.env.AGENTMAIL_GATEWAY_URL || 'http://127.0.0.1:8180';
|
||||
const AGENT_NAME = process.env.AGENTMAIL_AGENT_NAME || 'pi';
|
||||
const AGENT_SECRET = process.env.AGENTMAIL_AGENT_SECRET || '';
|
||||
const REPLY_PROVIDER = process.env.AGENTMAIL_REPLY_PROVIDER || '';
|
||||
const REPLY_MODEL = process.env.AGENTMAIL_REPLY_MODEL || '';
|
||||
const TURN_TIMEOUT_MS = Number(process.env.AGENTMAIL_TURN_TIMEOUT_MS || 60_000);
|
||||
const LOCK_FILE = join(process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail'), 'pi-bridge.lock');
|
||||
|
||||
/** 日志一律 console.error:它一定进 journalctl(契约 9.8)。 */
|
||||
const log = (...args) => console.error('[pi-mail-bridge]', ...args);
|
||||
|
||||
// ─── 进程内状态 ───
|
||||
//
|
||||
// 全部只在内存,重启即丢 —— 这是契约第六节列明的已知取舍。
|
||||
// 要持久化的话该落在 pi 的会话元数据里,而不是桥自己的文件。
|
||||
|
||||
const sessions = new Map(); // agentmail session_id -> { session, sessionManager, cwd }
|
||||
const reverseMap = new Map(); // pi session id -> agentmail session_id
|
||||
const mailDriven = new Set(); // pi session id
|
||||
const mailContexts = new Map(); // agentmail session_id -> { replyTo, subject, mailID }
|
||||
const relayedSummaries = new Map(); // pi session id -> 已转发过的 relay_key
|
||||
const syncedNames = new Map(); // pi session id -> 上次提交给 Gateway 的名字
|
||||
const pendingPermissions = new Map(); // relay_key -> { resolve, piSessionId }
|
||||
const deliveredMails = new Set(); // 已投过的 mail_id(SSE 与补拉共用,B-7.3)
|
||||
|
||||
let allowedModels = [];
|
||||
let modelRuntime = null;
|
||||
let client = null;
|
||||
let heartbeatTimer = null;
|
||||
let shuttingDown = false;
|
||||
|
||||
// ─── 单实例锁 ───
|
||||
//
|
||||
// 两个桥同时跑的后果不是「慢一点」而是错的:两条 SSE 各收到同一封邮件,
|
||||
// 各起一条 pi 会话,发件人收到两封回信;而 deliveredMails 在各自内存里,去重不了。
|
||||
|
||||
function acquireLock() {
|
||||
mkdirSync(join(LOCK_FILE, '..'), { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
// O_EXCL 原子创建。存在则说明有别的实例(或上次崩溃留下的陈锁)。
|
||||
const fd = openSync(LOCK_FILE, 'wx');
|
||||
writeFileSync(fd, String(process.pid));
|
||||
closeSync(fd);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e?.code !== 'EEXIST') throw e;
|
||||
}
|
||||
// 陈锁判定:文件里的 pid 还活着吗
|
||||
let pid = 0;
|
||||
try { pid = Number(readFileSync(LOCK_FILE, 'utf8').trim()); } catch { /* 读不到当陈锁 */ }
|
||||
if (pid > 0) {
|
||||
try {
|
||||
// signal 0 只探测存在性,不真的发信号
|
||||
process.kill(pid, 0);
|
||||
log(`已有实例在运行(pid ${pid}),本进程退出。`);
|
||||
return false;
|
||||
} catch {
|
||||
// ESRCH:进程没了,是陈锁
|
||||
}
|
||||
}
|
||||
log(`清理陈锁 ${LOCK_FILE}(原 pid ${pid || '未知'} 已不存在)`);
|
||||
try { unlinkSync(LOCK_FILE); } catch { /* 竞态下别人清掉了也行 */ }
|
||||
return acquireLock();
|
||||
}
|
||||
|
||||
function releaseLock() {
|
||||
try {
|
||||
// 只删自己的锁:pid 不符说明这把锁已被别的实例接管
|
||||
if (Number(readFileSync(LOCK_FILE, 'utf8').trim()) === process.pid) unlinkSync(LOCK_FILE);
|
||||
} catch { /* 已经没了 */ }
|
||||
}
|
||||
|
||||
// ─── 权限钩子(B-8)───
|
||||
|
||||
/**
|
||||
* 内联 pi 扩展:把 pi 拦下的危险工具调用转成一封邮件问人。
|
||||
*
|
||||
* 这是 `I-1` 最直接的体现 —— 被平台真正拦下的那一次才是事实,
|
||||
* 不依赖模型「记得」调 request_permission(它会忘,也会在不需要时乱调)。
|
||||
*
|
||||
* pi 的 `tool_call` 钩子**可以 await**(C-9 实测成立:处理器里 await 300ms
|
||||
* 再返回 {block:true},pi 会等),所以这里能真的等人做决定,
|
||||
* 不必走「先拒一次再重试」的退化路径。
|
||||
*
|
||||
* @param {string} piSessionIdRef 用一个 getter 拿会话 id:扩展工厂在
|
||||
* createAgentSession **内部**被调用,那时 session 对象还没返回给桥。
|
||||
*/
|
||||
function permissionExtension(getMailContext) {
|
||||
// pi 默认放行内建工具;桥只拦真正有副作用的那几个。
|
||||
// read/grep/ls 之类不拦:每一步都问人会让 Agent 什么也做不成,
|
||||
// 而人也会很快开始无脑点同意(那比不问更危险)。
|
||||
const GUARDED = new Set(['bash', 'write', 'edit']);
|
||||
|
||||
return (pi) => {
|
||||
pi.on('tool_call', async (event, ctx) => {
|
||||
if (!GUARDED.has(event.toolName)) return;
|
||||
|
||||
const piSessionId = ctx?.sessionManager?.getSessionId?.() || '';
|
||||
const mailSessionId = reverseMap.get(piSessionId);
|
||||
// 不是邮件驱动的会话 → 让位给 pi 自己的本地 UI(B-8.2)。
|
||||
// 占着钩子不放会让人在 TUI 里干活时每一步都卡住等邮件。
|
||||
if (!mailSessionId) return;
|
||||
|
||||
// relay_key 用 pi 给的 toolCallId(B-8.1):服务端会随决策事件回传它,
|
||||
// 桥重启丢了 pendingPermissions 也能对上(B-4.2)。自造随机 id 做不到。
|
||||
const relayKey = `${piSessionId}:${event.toolCallId}`;
|
||||
const ctxInfo = getMailContext(mailSessionId);
|
||||
|
||||
try {
|
||||
await client.post('/permission/request', {
|
||||
question: `是否允许执行 ${event.toolName}?`,
|
||||
options: ['同意', '一直同意', '拒绝'],
|
||||
context: describeToolCall(event),
|
||||
session_id: mailSessionId,
|
||||
to: ctxInfo?.replyTo || '',
|
||||
relay_key: relayKey,
|
||||
});
|
||||
} catch (e) {
|
||||
// 转发失败 → 让位给 pi 本地 UI(B-8.2)。返回 undefined 表示
|
||||
// 「这个钩子不表态」,pi 会走它自己的批准流程。
|
||||
log(`权限转发失败,让位给本地决策: ${describeError(e)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
log(`权限询问已发出(${event.toolName},key=${relayKey}),等待决策…`);
|
||||
const decision = await new Promise((resolve) => {
|
||||
pendingPermissions.set(relayKey, { resolve, piSessionId });
|
||||
});
|
||||
|
||||
// fail closed(B-9.2 / N-9):只有明确的同意才放行。
|
||||
// 关停时 shutdown() 会用 'shutdown' 唤醒所有等待者,落到这里的 else。
|
||||
if (/^(同意|一直同意|allow|approve|always|yes)/i.test(decision)) {
|
||||
log(`权限 ${relayKey} 获批(${decision}),放行 ${event.toolName}`);
|
||||
return;
|
||||
}
|
||||
return { block: true, reason: `用户${decision === 'shutdown' ? '未及决策(桥已关停)' : `拒绝了这次 ${event.toolName} 调用`}` };
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** 把一次工具调用摘要成人能判断的文本(B-8.4)。 */
|
||||
function describeToolCall(event) {
|
||||
const input = event?.input ?? {};
|
||||
if (event.toolName === 'bash') {
|
||||
return `命令:\n${String(input.command ?? '').slice(0, 800)}`;
|
||||
}
|
||||
if (event.toolName === 'write' || event.toolName === 'edit') {
|
||||
return `文件:${input.file_path ?? input.path ?? '(未给出)'}`;
|
||||
}
|
||||
return JSON.stringify(input).slice(0, 800);
|
||||
}
|
||||
|
||||
// ─── 会话解析(B-3)───
|
||||
|
||||
/**
|
||||
* 没有可用 `to_workspace` 时的兜底目录。
|
||||
*
|
||||
* 与 DSH 的 `mailSessionFallback` 同构,但目录名是 `.pi`:那个函数在
|
||||
* lib/ 下(三平台逐字节相同),写死了 `.dsh`,不能为 pi 改。
|
||||
* 让 pi 的会话落进 `~/.dsh/` 会让人以为是 DSH 在干活。
|
||||
*/
|
||||
function piMailFallback(sessionKey) {
|
||||
return join(homedir(), '.pi', 'mail-sessions', String(sessionKey || 'default'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 找到(或建立)这封邮件该落进的 pi 会话。
|
||||
*
|
||||
* Gateway 已经按三维地址的 session 位做完了「复用默认 / 新建 / 具名必须存在」
|
||||
* 的判定,推来的 session_id 就是判定结果 —— 桥只负责忠实映射,
|
||||
* 不自己决定开不开新会话(N-8:404 后自动改用 .new 是禁止的)。
|
||||
*/
|
||||
async function resolveSession(data, mailTools) {
|
||||
const mailSessionID = data.session_id;
|
||||
const bound = mailSessionID ? sessions.get(mailSessionID) : undefined;
|
||||
if (bound) return { ...bound, reused: true };
|
||||
|
||||
// cwd 取寻址里的 path 位(B-3.1)。校验走共用模块:目录不存在时**不创建**
|
||||
// (N-2:笔误会在磁盘上落下真目录,而 Agent 在里面一无所获),拒绝相对路径(N-3)。
|
||||
//
|
||||
// 兜底用 `~/.pi/mail-sessions/<会话>` 而不是共用模块里的 mailSessionFallback ——
|
||||
// 后者写死了 `.dsh` 目录名(那是 DSH 的家),pi 的会话落进去会让人以为
|
||||
// DSH 在干活。lib/ 里的函数三平台逐字节相同,不能为 pi 改它。
|
||||
const { cwd, grouped } = resolveWorkspaceCwd(data.to_workspace, piMailFallback(mailSessionID));
|
||||
if (!grouped && data.to_workspace) {
|
||||
log(`工作目录 ${data.to_workspace} 不可用,回退到 ${cwd}`);
|
||||
}
|
||||
ensureCwd(cwd, grouped);
|
||||
|
||||
const opened = await openSession({
|
||||
cwd,
|
||||
modelRuntime,
|
||||
customTools: mailTools,
|
||||
extension: permissionExtension((id) => mailContexts.get(id)),
|
||||
});
|
||||
for (const d of opened.diagnostics) {
|
||||
log(`扩展诊断: ${d?.message ?? JSON.stringify(d)}`);
|
||||
}
|
||||
|
||||
const piSessionId = opened.session.sessionId;
|
||||
const entry = { session: opened.session, sessionManager: opened.sessionManager, cwd };
|
||||
|
||||
if (mailSessionID) {
|
||||
sessions.set(mailSessionID, entry);
|
||||
reverseMap.set(piSessionId, mailSessionID);
|
||||
mailDriven.add(piSessionId);
|
||||
}
|
||||
|
||||
// 一轮结束就转发总结(B-5)。挂 agent_end 而不是 message_end:
|
||||
// 后者在流式生成中反复触发,转出去的是半截话。
|
||||
// subscribe 收的是一个普通函数(AgentSessionEventListener),不是 {onEvent}。
|
||||
opened.session.subscribe((event) => {
|
||||
if (event?.type === 'agent_end') {
|
||||
// willRetry 为真表示 pi 自己要重试(auto_retry),这一轮还没定论 —— 不转。
|
||||
if (event.willRetry) return;
|
||||
relaySummary(piSessionId).catch((e) => log(`自动转发失败: ${describeError(e)}`));
|
||||
}
|
||||
// pi 侧改名(pi-web 生成标题、人在 TUI 里 /name)→ 同步给 Gateway
|
||||
if (event?.type === 'session_info_changed') {
|
||||
syncNaming(piSessionId, event.name).catch((e) => log(`命名同步失败: ${describeError(e)}`));
|
||||
}
|
||||
});
|
||||
|
||||
log(`新建 pi 会话 ${piSessionId}(cwd=${cwd})`);
|
||||
return { ...entry, reused: false };
|
||||
}
|
||||
|
||||
// ─── 命名一致(C-11 / W-7)───
|
||||
|
||||
/**
|
||||
* pi 的名字 → Gateway → 定稿别名回写进 pi。
|
||||
*
|
||||
* 完整推理见 src/naming.mjs 顶部。这里只是把那套决策接上 I/O。
|
||||
*/
|
||||
async function syncNaming(piSessionId, platformName) {
|
||||
const mailSessionID = reverseMap.get(piSessionId);
|
||||
if (!mailSessionID) return; // 不是邮件驱动的会话,不碰
|
||||
|
||||
const plan = planNamingSync({
|
||||
platformName,
|
||||
mailSubject: mailContexts.get(mailSessionID)?.subject,
|
||||
lastSynced: syncedNames.get(piSessionId),
|
||||
});
|
||||
if (plan.skip) return;
|
||||
|
||||
// 先记下指纹再发请求:响应回来时 setSessionName 会再次触发
|
||||
// session_info_changed,这一步是防自激循环的关键。
|
||||
syncedNames.set(piSessionId, plan.signature);
|
||||
|
||||
const res = await client.post(`/sessions/${mailSessionID}/sync`, {
|
||||
alias: plan.alias,
|
||||
title: plan.title,
|
||||
});
|
||||
|
||||
const entry = sessions.get(mailSessionID);
|
||||
const back = planWriteBack({
|
||||
finalAlias: res?.alias,
|
||||
currentPiName: entry?.session?.sessionName,
|
||||
});
|
||||
log(`命名同步 ${piSessionId}: alias=${res?.alias || '(未变)'} 来源=${plan.source}`);
|
||||
|
||||
if (back.write && entry?.session) {
|
||||
// 顺序要紧:先更新指纹,再改名。
|
||||
//
|
||||
// setSessionName **同步**触发 session_info_changed(实测),于是本函数会在
|
||||
// 这一行里被重入。指纹在改名之后才更新的话,重入那次看到的还是旧指纹,
|
||||
// 于是又打一次 sync —— 每条会话两次请求,内容完全相同。
|
||||
//
|
||||
// 记的是「把定稿别名当作平台名字」会算出的指纹:重入那次的 platformName
|
||||
// 正是 back.name,来源判定成 platform,算出来的就是这个值。
|
||||
syncedNames.set(piSessionId, `platform:${back.name}|${back.name}`);
|
||||
// 只用 setSessionName(走 pi 自己的写入路径)。绝不自己拼路径写会话文件:
|
||||
// 首条 assistant 消息落盘前文件还不存在,pi 首次落盘用 openSync(file,"wx"),
|
||||
// 抢先创建会让它抛 EEXIST(实测)。
|
||||
entry.session.setSessionName(back.name);
|
||||
log(`别名回写 pi:${back.name}(${back.reason})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 自动转发(B-5)───
|
||||
|
||||
async function relaySummary(piSessionId) {
|
||||
const mailSessionID = reverseMap.get(piSessionId);
|
||||
if (!mailSessionID) return;
|
||||
// 只对邮件驱动的会话转发(B-5.5):人在 pi 里正常干活时不该往邮箱灌总结
|
||||
if (!mailDriven.has(piSessionId)) return;
|
||||
|
||||
const entry = sessions.get(mailSessionID);
|
||||
if (!entry) return;
|
||||
|
||||
// 一轮结束是命名的自然时机(C-11 / D-5)。
|
||||
//
|
||||
// 这一步不能只挂在 session_info_changed 上:桥用 SDK 起的会话**永远不会**
|
||||
// 触发那个事件 —— pi 的标题生成器在 pi-web 里,不在内核里,SDK 路径上没有它。
|
||||
// 只等事件的话别名永远是空的,于是 `name@path.<别名>` 续谈无从下手
|
||||
// (实测过:第一封邮件跑通了,sessions.session_alias 仍是空串)。
|
||||
//
|
||||
// 放在转发**之前**:回信里会带上会话别名,收件人看到的第一封回信就能用它续谈。
|
||||
await syncNaming(piSessionId, entry.session.sessionName)
|
||||
.catch((e) => log(`命名同步失败: ${describeError(e)}`));
|
||||
|
||||
// 只取 type==='text' 的块(B-5.1 / N-6):thinking 是思考过程,不是结论
|
||||
const text = lastAssistantText(entry.session.messages);
|
||||
if (!text) return; // 空文本不发空邮件(B-5.4)
|
||||
|
||||
const ctx = mailContexts.get(mailSessionID);
|
||||
if (!ctx?.replyTo) return; // 不知道回给谁
|
||||
|
||||
// 幂等键用 pi 的会话 id + 会话树叶子 id:两者都落盘,重启重放也是同一个键。
|
||||
const relayKey = relayKeyFor(piSessionId, entry.sessionManager.getLeafId?.());
|
||||
if (relayedSummaries.get(piSessionId) === relayKey) return;
|
||||
|
||||
// 模型这一轮已亲手回过这条线索 → 让位(B-5.3)。
|
||||
// 否则收件箱里是两封说同一件事的邮件(生产实测过)。
|
||||
if (shouldSkipAutoRelay(explicitSends.get(piSessionId), ctx.replyTo, ctx.mailID)) {
|
||||
explicitSends.delete(piSessionId);
|
||||
relayedSummaries.set(piSessionId, relayKey);
|
||||
log(`本轮模型已主动回信 ${ctx.replyTo},跳过自动转发`);
|
||||
return;
|
||||
}
|
||||
|
||||
await client.post('/mail/send', {
|
||||
to: ctx.replyTo,
|
||||
subject: replySubject(ctx.subject),
|
||||
body: text,
|
||||
reply_to: ctx.mailID || '',
|
||||
// relay + relay_key 走免配额通道(I-2):模型已经把话说完了,
|
||||
// 桥只是把它搬到邮件里。对搬运收费会让配额用尽时 Agent 连交代都做不了。
|
||||
relay: 'summary',
|
||||
relay_key: relayKey,
|
||||
});
|
||||
relayedSummaries.set(piSessionId, relayKey);
|
||||
explicitSends.delete(piSessionId); // 一轮结束,窗口关闭
|
||||
log(`已转发本轮总结给 ${ctx.replyTo}(${text.length} 字)`);
|
||||
}
|
||||
|
||||
// ─── 投递(B-3 / B-6)───
|
||||
|
||||
async function deliverMail(data, kind, mailTools) {
|
||||
const { session, reused } = await resolveSession(data, mailTools);
|
||||
const piSessionId = session.sessionId;
|
||||
|
||||
// 新一轮开始:清掉上一轮「模型主动发过信」的记录。不清的话,
|
||||
// 上一轮亲手回过信会永久压掉这个会话之后所有的自动转发。
|
||||
explicitSends.delete(piSessionId);
|
||||
|
||||
if (kind === 'mail' && data.session_id) {
|
||||
// 一个会话里可能来过多封信,只留最近那封 —— 回信要落回最新的线索
|
||||
mailContexts.set(data.session_id, {
|
||||
replyTo: data.from_name || '',
|
||||
subject: data.subject || '',
|
||||
mailID: data.mail_id || '',
|
||||
});
|
||||
}
|
||||
|
||||
const prompt = buildMailPrompt({ agentName: AGENT_NAME, data, kind, reused });
|
||||
|
||||
// 续谈:会话已经存在,模型也已经定了(pi 的模型在 createAgentSession 时绑定),
|
||||
// 所以这一支不做模型降级。runTurn 内部按 isStreaming 分流:
|
||||
// 空闲就直接起一轮,正在跑就排到当轮之后(不打断上一封邮件的工作)。
|
||||
if (reused) {
|
||||
const outcome = await runTurn(session, prompt, TURN_TIMEOUT_MS);
|
||||
log(`续谈 ${piSessionId}(mail ${data.mail_id}${outcome.queued ? ',已排队' : ''})`);
|
||||
// 续谈失败不换模型重试(换模型要换会话,会丢掉整条上下文 ——
|
||||
// 而上下文正是发件人指定这条会话的原因),但要让失败可见。
|
||||
if (!outcome.ok) throw new Error(`续谈失败: ${outcome.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 按管理员划定的范围逐个尝试(D-3)。
|
||||
// 关键点:`prompt()` resolve **不代表模型跑成功了** —— 无凭证的 provider
|
||||
// 会让它 reject(实测 `No API key found for amazon-bedrock.`),
|
||||
// 而上游报错走 stopReason==='error'。判定交给 classifyTurnOutcome。
|
||||
const attempts = modelAttemptOrder(allowedModels, {
|
||||
provider: REPLY_PROVIDER,
|
||||
model: REPLY_MODEL,
|
||||
});
|
||||
const failures = [];
|
||||
|
||||
for (const route of attempts) {
|
||||
const label = route ? `${route.provider}/${route.model}` : '(平台默认)';
|
||||
if (route) {
|
||||
const model = modelRuntime.getModel(route.provider, route.model);
|
||||
if (!model) {
|
||||
// 目录里根本没有这个路由:同步就能判定,不必起一轮
|
||||
failures.push({ ...route, error: `平台目录里没有 ${label}` });
|
||||
log(`模型 ${label} 不存在,跳过`);
|
||||
continue;
|
||||
}
|
||||
// 换模型要换会话:pi 的模型在 createAgentSession 时绑定。
|
||||
// 上一次尝试失败的会话没有任何 assistant 消息,丢掉不损失内容。
|
||||
const cwd = sessions.get(data.session_id)?.cwd;
|
||||
const current = sessions.get(data.session_id)?.session;
|
||||
current?.dispose?.();
|
||||
const retried = await openSession({
|
||||
cwd,
|
||||
modelRuntime,
|
||||
model,
|
||||
customTools: mailTools,
|
||||
extension: permissionExtension((id) => mailContexts.get(id)),
|
||||
});
|
||||
rebind(data.session_id, current?.sessionId ?? piSessionId, retried, cwd);
|
||||
const outcome = await runTurn(retried.session, prompt, TURN_TIMEOUT_MS);
|
||||
if (outcome.ok) {
|
||||
if (failures.length) log(`${label} 成功(前 ${failures.length} 个失败)`);
|
||||
return;
|
||||
}
|
||||
failures.push({ ...route, error: outcome.error });
|
||||
log(`模型 ${label} 失败: ${outcome.error}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const outcome = await runTurn(session, prompt, TURN_TIMEOUT_MS);
|
||||
if (outcome.ok) {
|
||||
if (failures.length) log(`${label} 成功(前 ${failures.length} 个失败)`);
|
||||
return;
|
||||
}
|
||||
failures.push({ error: outcome.error });
|
||||
log(`模型 ${label} 失败: ${outcome.error}`);
|
||||
}
|
||||
|
||||
// 全部失败 → 必须回信(B-6):模型一次都没跑起来,会话里没有任何
|
||||
// assistant 消息,自动转发因此什么也不会发 —— 发件人只会看到再无音讯。
|
||||
if (kind === 'mail' && data.from_name) {
|
||||
try {
|
||||
await client.post('/mail/send', {
|
||||
to: data.from_name,
|
||||
subject: `处理失败: ${data.subject || '(无主题)'}`,
|
||||
body: renderFailureReport(failures, data.subject),
|
||||
reply_to: data.mail_id || '',
|
||||
relay: 'summary',
|
||||
relay_key: `model-failure:${data.mail_id || piSessionId}`,
|
||||
});
|
||||
log(`已回报模型调用失败给 ${data.from_name}`);
|
||||
} catch (e) {
|
||||
log(`失败回报也发不出去: ${describeError(e)}`);
|
||||
}
|
||||
}
|
||||
// 发完仍要 throw(B-6.4):静默会让这次失败只存在于邮件里,日志上看不出来
|
||||
throw new Error(`范围内 ${failures.length} 个模型全部失败:${failures.map(f => f.error).join(' | ')}`);
|
||||
}
|
||||
|
||||
/** 换模型重开会话后,把三张映射表指向新会话。 */
|
||||
function rebind(mailSessionID, oldPiId, opened, cwd) {
|
||||
reverseMap.delete(oldPiId);
|
||||
mailDriven.delete(oldPiId);
|
||||
const piSessionId = opened.session.sessionId;
|
||||
// cwd 由调用方传:AgentSession 上没有 cwd getter(只有 sessionId /
|
||||
// sessionFile / sessionName),从 sessionManager.getCwd() 也行,
|
||||
// 但这里本来就有那个值,多绕一层没有意义。
|
||||
const entry = { session: opened.session, sessionManager: opened.sessionManager, cwd };
|
||||
if (mailSessionID) {
|
||||
sessions.set(mailSessionID, entry);
|
||||
reverseMap.set(piSessionId, mailSessionID);
|
||||
mailDriven.add(piSessionId);
|
||||
}
|
||||
opened.session.subscribe((event) => {
|
||||
if (event?.type === 'agent_end' && !event.willRetry) {
|
||||
relaySummary(piSessionId).catch((e) => log(`自动转发失败: ${describeError(e)}`));
|
||||
}
|
||||
if (event?.type === 'session_info_changed') {
|
||||
syncNaming(piSessionId, event.name).catch((e) => log(`命名同步失败: ${describeError(e)}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 权限决策回来(B-4)───
|
||||
|
||||
async function handlePermissionDecision(data, mailTools) {
|
||||
const relayKey = data.relay_key || '';
|
||||
const pending = relayKey ? pendingPermissions.get(relayKey) : undefined;
|
||||
|
||||
if (pending) {
|
||||
pendingPermissions.delete(relayKey);
|
||||
pending.resolve(String(data.decision || '拒绝'));
|
||||
log(`权限 ${relayKey} 决策 ${data.decision}(决策人 ${data.decided_by || '?'})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 找不到挂起项(桥重启丢了内存映射)→ 退化为把决策当一封通知投进原会话(B-4.2)。
|
||||
// 此时 pi 侧那次工具调用早已随进程消失,但人刚刚点了「同意」——
|
||||
// 什么都不做的话人以为自己批准了、Agent 却毫无反应。
|
||||
if (!data.session_id || !sessions.has(data.session_id)) {
|
||||
// **不得凭空新开会话**(B-4.3)
|
||||
log(`权限决策 ${relayKey} 无对应会话,忽略`);
|
||||
return;
|
||||
}
|
||||
log(`权限 ${relayKey} 无挂起项,退化为通知投递`);
|
||||
await deliverMail(data, 'permission', mailTools);
|
||||
}
|
||||
|
||||
// ─── 心跳(B-2)───
|
||||
|
||||
async function reportSessions() {
|
||||
try {
|
||||
const { SessionManager } = await import('@earendil-works/pi-coding-agent');
|
||||
// 不传参数:`listAll(dir)` 把字符串当**自定义会话目录**,传 getAgentDir()
|
||||
// 会去 ~/.pi/agent 下直接找 .jsonl(那里没有),得到空列表。
|
||||
// 不传时它用默认的 ~/.pi/agent/sessions,逐个 cwd 子目录扫。
|
||||
//
|
||||
// 用 listAll 而不是 list(cwd):桥的进程 cwd 与会话 cwd 无关,
|
||||
// 按前者过滤会漏掉所有真正在干活的会话。
|
||||
const all = await SessionManager.listAll();
|
||||
return snapshotPiSessions(all, (id) => mailDriven.has(id));
|
||||
} catch (e) {
|
||||
// 拉不到就**省略字段**而不是传 [](N-7 / W-3):
|
||||
// 空数组的语义是「平台确实一条会话都没有」,会把服务端镜像抹掉。
|
||||
log(`会话列表读取失败: ${describeError(e)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function reportModels() {
|
||||
try {
|
||||
// getAvailable 而不是 getModels:后者本机有 1221 条,其中真能调起来的只有 1 条。
|
||||
// 上报目录的全部意义就是让管理员别选中一个注定失败的路由。
|
||||
const available = await modelRuntime.getAvailable();
|
||||
return snapshotPiModels(available);
|
||||
} catch (e) {
|
||||
log(`模型目录读取失败: ${describeError(e)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function catchUp(pending, mailTools) {
|
||||
if (!pending) return;
|
||||
try {
|
||||
const box = await client.get('/mail/inbox?status=unread&limit=20');
|
||||
const tasks = selectCatchup(box?.mails ?? box, deliveredMails);
|
||||
if (!tasks.length) return;
|
||||
log(`补投 ${tasks.length} 封离线期间的邮件(共 ${pending} 封未读)`);
|
||||
// 串行(B-7.2):每封都要起一轮模型,并发放出去等于对上游打 N 个并发请求
|
||||
for (const ev of tasks) {
|
||||
if (deliveredMails.has(ev.mail_id)) continue; // 逐封再查(B-7.6)
|
||||
deliveredMails.add(ev.mail_id);
|
||||
try {
|
||||
await deliverMail(ev, 'mail', mailTools);
|
||||
} catch (e) {
|
||||
log(`补投 ${ev.mail_id} 失败: ${describeError(e)}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log(`补投失败: ${describeError(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 启动 / 关停 ───
|
||||
|
||||
async function main() {
|
||||
if (!acquireLock()) process.exit(0);
|
||||
|
||||
// B-1.1:环境变量 → ~/.agentmail/agent.key → 本地生成并打印全文
|
||||
let agentKey = process.env.AGENTMAIL_AGENT_KEY || readLocalKey();
|
||||
if (!agentKey && !AGENT_SECRET) agentKey = generateLocalKey(log);
|
||||
|
||||
client = new GatewayClient({
|
||||
url: GATEWAY_URL,
|
||||
agentName: AGENT_NAME,
|
||||
agentKey,
|
||||
agentSecret: AGENT_SECRET,
|
||||
});
|
||||
|
||||
// ModelRuntime 建一次全进程共用:它要读 auth.json / models.json 并做
|
||||
// 可用性探测,每条会话建一个既慢又会重复打 provider 的探测请求。
|
||||
//
|
||||
// allowModelNetwork 保持默认的 false:桥启动时不去网上拉模型目录。
|
||||
// 拉了也没用 —— 上报给 Gateway 的是 getAvailable()(有凭证、真能调起来的),
|
||||
// 而那取决于本机 auth.json,不取决于目录里有多少条。开着只会让
|
||||
// 启动多等一个网络往返,而且断网时启动路径上多一个可失败点。
|
||||
modelRuntime = await ModelRuntime.create();
|
||||
const runtimeErr = modelRuntime.getError?.();
|
||||
if (runtimeErr) log(`模型运行时告警: ${runtimeErr}`);
|
||||
|
||||
const mailTools = createMailTools({ client, log, agentName: AGENT_NAME });
|
||||
|
||||
try {
|
||||
await client.register(); // B-1.2
|
||||
saveConfig({ gateway_url: GATEWAY_URL, agent_name: AGENT_NAME, registered_at: new Date().toISOString() });
|
||||
log(`已接入 ${GATEWAY_URL},身份 ${AGENT_NAME}(${agentKey ? '密钥认证' : 'name/secret 认证'})。`);
|
||||
} catch (e) {
|
||||
// 密钥未登记时这里报「密钥无效」—— 必须说清该做什么,
|
||||
// 否则用户只看到一句 401,不知道要拿密钥去后台登记。
|
||||
log(`注册失败: ${describeError(e)}`);
|
||||
if (agentKey) log(`若提示密钥无效,请让管理员在 AgentMail 后台登记这把密钥。`);
|
||||
}
|
||||
|
||||
let caughtUp = false;
|
||||
const beat = async () => {
|
||||
const [platform_sessions, models] = await Promise.all([reportSessions(), reportModels()]);
|
||||
const body = {};
|
||||
if (platform_sessions) body.platform_sessions = platform_sessions;
|
||||
if (models) body.models = models;
|
||||
try {
|
||||
const res = await client.post('/agent/heartbeat', body);
|
||||
if (Array.isArray(res?.allowed_models)) allowedModels = res.allowed_models; // B-2.2
|
||||
if (!caughtUp) { // B-7.1:只在首个成功心跳后补一次
|
||||
caughtUp = true;
|
||||
await catchUp(res?.pending_mails, mailTools);
|
||||
}
|
||||
} catch {
|
||||
// B-2.1:心跳失败不重试不报错。真连不上时 Gateway 会把它判成离线,
|
||||
// 那才是可见的信号;桥自己打一串错误日志只会淹掉真正的问题。
|
||||
}
|
||||
};
|
||||
await beat(); // B-1.3:不等第一个 30 秒周期
|
||||
heartbeatTimer = setInterval(beat, 30_000); // B-1.5
|
||||
|
||||
client.startSSE((type, data) => { // B-1.4:首次不带 Last-Event-ID
|
||||
if (type === 'permission_decision') {
|
||||
handlePermissionDecision(data, mailTools).catch((e) =>
|
||||
log(`权限决策处理失败: ${describeError(e)}`));
|
||||
return;
|
||||
}
|
||||
if (type !== 'new_mail') return;
|
||||
if (data?.role && data.role !== 'to' && data.role !== 'cc') return;
|
||||
const id = data?.mail_id;
|
||||
if (!id || deliveredMails.has(id)) return; // B-3 第 1 步:去重
|
||||
deliveredMails.add(id);
|
||||
deliverMail(data, 'mail', mailTools).catch((e) => log(`投递 ${id} 失败: ${describeError(e)}`));
|
||||
}, log);
|
||||
|
||||
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => shutdown(sig));
|
||||
}
|
||||
|
||||
function shutdown(reason) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
log(`收到 ${reason},关停中…`);
|
||||
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer); // B-9.1
|
||||
client?.stopSSE();
|
||||
|
||||
// B-9.2 / N-9:所有未决权限询问 fail closed。
|
||||
// 不唤醒的话 pi 侧那些 await 永不返回,整条会话挂死;
|
||||
// 而默认放行一个没人批准的危险操作,比让它失败严重得多。
|
||||
for (const [key, p] of pendingPermissions) {
|
||||
log(`未决权限 ${key} fail closed`);
|
||||
p.resolve('shutdown');
|
||||
}
|
||||
pendingPermissions.clear();
|
||||
|
||||
for (const { session } of sessions.values()) {
|
||||
try { session.dispose?.(); } catch { /* 关停期的报错没有价值 */ }
|
||||
}
|
||||
releaseLock();
|
||||
// B-9.3:不发「插件下线」通知邮件
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
log(`启动失败: ${describeError(e)}`);
|
||||
releaseLock();
|
||||
process.exit(1);
|
||||
});
|
||||
115
plugins/pi-mail-bridge/src/naming.mjs
Normal file
115
plugins/pi-mail-bridge/src/naming.mjs
Normal file
@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 会话命名的双向一致(C-11 / W-7 / D-5)。
|
||||
*
|
||||
* 一句话:**Gateway 定稿,pi 接受定稿**。
|
||||
*
|
||||
* pi/pi-web 生成名字 ──①观测──▶ 桥 ──②POST /sessions/{id}/sync──▶ Gateway
|
||||
* pi 的 session_info ◀──④回写──── ③响应里的 final alias
|
||||
*
|
||||
* 为什么不能各自命名然后指望撞上:别名在 AgentMail 侧负有寻址唯一性义务
|
||||
* (partial unique index + 撞名自动追 -2/-3),pi 侧没有这个约束。
|
||||
* 而 `alias_source='manual'` 的会话(人在界面上改过名)永远不接受平台同步,
|
||||
* `SyncSessionAlias` 会把**当前别名原样返回**。所以只有用响应里的值回写,
|
||||
* 两边看到的才是同一个名字。单向推送做不到这一点。
|
||||
*
|
||||
* ④ 必须判「与上次写入的值不同」才执行,否则 setSessionName 触发
|
||||
* session_info_changed,钩子又去 sync,成自激循环。
|
||||
*
|
||||
* 三个实测出来的约束(探针脚本验证过,见 test/naming.test.mjs 里的注释):
|
||||
* - pi 首条 assistant 消息落盘前会话文件**不存在**,SessionManager 首次落盘用
|
||||
* `openSync(file, "wx")`;桥抢先按路径写会让 pi 侧 flush 抛 EEXIST。
|
||||
* → 回写只用 `session.setSessionName()`(走 pi 自己的写入路径),
|
||||
* 绝不自己拼路径写文件。
|
||||
* - 活着的 SessionManager 不 watch 文件;外部改名它看不见,之后它自己
|
||||
* append 一条 session_info 反而会盖掉外部的("最后一条生效")。
|
||||
* - 空名字是**清除**语义(`appendSessionInfo(" ")` 之后 getSessionName() 变
|
||||
* undefined),因此不能用空串表达「无变化」。
|
||||
*/
|
||||
|
||||
import { slugFromTitle, isUnusableName } from '../lib/session-snapshot.js';
|
||||
|
||||
/**
|
||||
* 决定这一轮要不要向 Gateway 同步命名,以及同步什么。
|
||||
*
|
||||
* 别名的降级阶梯(D-5):
|
||||
* 1. 平台生成的名字派生的 slug
|
||||
* 2. 名字不可用(pi-web 的思维链泄漏、纯符号)或**根本没有名字**
|
||||
* → 退到邮件主题派生
|
||||
* 3. 两者都没有 → **不写回**(W-7.2:绝不写占位别名)
|
||||
*
|
||||
* 第 2 步里的「根本没有名字」是 pi 的常态而非例外:桥用 SDK 起的会话不经过
|
||||
* pi-web 的标题生成器(那个生成器在 pi-web 包里,不在 pi 内核里),
|
||||
* 因此 `session.sessionName` 一直是 undefined。只等平台命名的话别名永远是空的,
|
||||
* `name@path.<别名>` 续谈无从下手 —— 实测过这个后果。
|
||||
*
|
||||
* 标题一律用平台原文(不派生、不清洗):`I-4` 说插件只搬运。
|
||||
* 唯一的例外是判废 —— 判废的结果是「不写」,不是「改写成别的」。
|
||||
*
|
||||
* 返回值里的 `signature` 是「本次提交内容的指纹」,调用方存下它并在下一轮
|
||||
* 作为 `lastSynced` 传回,用来判「没变化就别重复提交」。**不能用平台名字本身**
|
||||
* 充当这个角色:名字为空时(上面那个常态)它无法区分「还没提交过」与
|
||||
* 「提交过、内容没变」,于是每轮心跳都白打一次 sync。
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {string} input.platformName pi 侧 session_info 里的名字
|
||||
* @param {string} input.mailSubject 该会话最近一封来信的主题(兜底用)
|
||||
* @param {string} input.lastSynced 上一次提交的 signature
|
||||
* @returns {{skip: true, reason: string} | {skip: false, alias: string, title: string, source: string, signature: string}}
|
||||
*/
|
||||
export function planNamingSync({ platformName, mailSubject, lastSynced }) {
|
||||
const name = String(platformName ?? '').trim();
|
||||
const prev = String(lastSynced ?? '').trim();
|
||||
|
||||
const decide = () => {
|
||||
if (name && !isUnusableName(name)) {
|
||||
const alias = slugFromTitle(name);
|
||||
// 名字看着正常但全是分隔符("..." / "@@@")→ 派生不出别名,
|
||||
// 但**标题仍然值得写**:subject 那一列不负责寻址,没有字符限制。
|
||||
if (alias) return { alias, title: name, source: 'platform' };
|
||||
return { alias: '', title: name, source: 'platform-title-only' };
|
||||
}
|
||||
|
||||
// 平台名字不可用或不存在:退到邮件主题。它是人写的,
|
||||
// 天然比模型的思维链靠谱,而 SDK 起的会话本来就没有平台名字。
|
||||
const subject = String(mailSubject ?? '').trim();
|
||||
if (subject) {
|
||||
const alias = slugFromTitle(subject);
|
||||
if (alias) return { alias, title: '', source: 'mail-subject' };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const plan = decide();
|
||||
|
||||
// 什么都没有:不写。宁可让会话保持无别名(数据库允许 NULL),
|
||||
// 也不要写一个 "session-123" 这样的占位值 —— 那种别名对人毫无指代作用,
|
||||
// 而且一旦落库就把 alias 位占住了,真正的名字来了也只能追 -2 后缀。
|
||||
if (!plan) return { skip: true, reason: 'no-usable-name' };
|
||||
|
||||
const signature = `${plan.source}:${plan.alias}|${plan.title}`;
|
||||
if (signature === prev) return { skip: true, reason: 'unchanged' };
|
||||
return { ...plan, skip: false, signature };
|
||||
}
|
||||
|
||||
/**
|
||||
* 决定要不要把 Gateway 定稿的别名回写进 pi。
|
||||
*
|
||||
* 回写的三种触发情形:
|
||||
* - 撞名:提议 `fix-leak`,Gateway 给了 `fix-leak-2`
|
||||
* - manual 保护:人在界面上改成了 `紧急排查`,Gateway 原样返回它
|
||||
* - 规范化:提议里含 `.` `@` `/` 空白,被 normalizeAlias 换成了 `-`
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {string} input.finalAlias Gateway 响应里的 alias
|
||||
* @param {string} input.currentPiName pi 侧当前的名字
|
||||
* @returns {{write: boolean, name: string, reason: string}}
|
||||
*/
|
||||
export function planWriteBack({ finalAlias, currentPiName }) {
|
||||
const final = String(finalAlias ?? '').trim();
|
||||
// 服务端没回别名(本次只同步了标题)→ 没有定稿值可写
|
||||
if (!final) return { write: false, name: '', reason: 'no-alias-in-response' };
|
||||
const cur = String(currentPiName ?? '').trim();
|
||||
if (cur === final) return { write: false, name: '', reason: 'already-equal' };
|
||||
// 空名字是清除语义,这里 final 非空,所以安全
|
||||
return { write: true, name: final, reason: cur ? 'diverged' : 'pi-unnamed' };
|
||||
}
|
||||
137
plugins/pi-mail-bridge/src/session-pool.mjs
Normal file
137
plugins/pi-mail-bridge/src/session-pool.mjs
Normal file
@ -0,0 +1,137 @@
|
||||
/**
|
||||
* pi 会话池 —— 每条 AgentMail 会话对应一条 pi 会话。
|
||||
*
|
||||
* 为什么桥必须自己持有 pi 会话(而不是写成一个 pi 扩展):
|
||||
* 扩展被加载进**一条已经存在的**会话里,cwd 由启动 pi 的人决定;而 B-3.1 要求
|
||||
* 每封邮件的 to_workspace 成为会话 cwd。扩展做不到「按邮件新开一条 cwd 不同的
|
||||
* 会话」,所以桥是一个常驻进程(C-7),用 SDK 的 createAgentSession 起会话。
|
||||
*
|
||||
* 每条会话一套 SettingsManager / ResourceLoader / SessionManager:它们都按 cwd
|
||||
* 解析项目级配置(.pi/、skills、prompts),共用一份会把 A 项目的配置带进 B 项目。
|
||||
*/
|
||||
|
||||
import { createAgentSession, SessionManager, SettingsManager, DefaultResourceLoader, getAgentDir }
|
||||
from '@earendil-works/pi-coding-agent';
|
||||
|
||||
/**
|
||||
* 起一条 pi 会话。
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.cwd 会话工作目录(已由 resolveWorkspaceCwd 校验过存在)
|
||||
* @param {any} opts.modelRuntime 共享的 ModelRuntime(建一次很贵,池外传进来)
|
||||
* @param {any} [opts.model] 指定模型;省略则用 settings 里的默认
|
||||
* @param {any[]} opts.customTools 邮件工具(send_mail / read_inbox / …)
|
||||
* @param {(pi: any) => void} [opts.extension] 内联扩展工厂,用来挂 tool_call 权限钩子
|
||||
* @returns {Promise<{session: any, sessionManager: any, diagnostics: any[]}>}
|
||||
*/
|
||||
export async function openSession({ cwd, modelRuntime, model, customTools, extension }) {
|
||||
const agentDir = getAgentDir();
|
||||
const settingsManager = SettingsManager.create(cwd, agentDir);
|
||||
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
// 关掉磁盘上的全局扩展。两个理由:
|
||||
// 1. 本机的 pi-a2a / pi-acp 在加载时 listen 固定端口(12010/12011),
|
||||
// 守护进程里加载会 EADDRINUSE,把整条会话拖死。
|
||||
// 2. 桥起的会话是给邮件用的,不该继承人类交互用的那套扩展(TUI 命令、
|
||||
// 快捷键、状态栏都没有意义)。
|
||||
// 邮件工具走 customTools,权限钩子走下面的 extensionFactories。
|
||||
noExtensions: true,
|
||||
extensionFactories: extension
|
||||
? [{ name: 'agentmail-bridge', factory: extension }]
|
||||
: [],
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
|
||||
const sessionManager = SessionManager.create(cwd);
|
||||
const created = await createAgentSession({
|
||||
cwd,
|
||||
agentDir,
|
||||
modelRuntime,
|
||||
// model 为 undefined 时 SDK 用 settings 里的默认模型,正好对应
|
||||
// modelAttemptOrder 里那个 `undefined`(= 不指定、交给平台)。
|
||||
...(model ? { model } : {}),
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
resourceLoader,
|
||||
customTools,
|
||||
});
|
||||
|
||||
return {
|
||||
session: created.session,
|
||||
sessionManager,
|
||||
diagnostics: created.extensionsResult?.diagnostics ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 跑一轮并等到真正的结论(C-4 / D-3)。
|
||||
*
|
||||
* `session.prompt()` 的 promise 在**这一轮彻底结束**时才 resolve,所以不需要
|
||||
* 额外订阅 agent_end 去等。但它 resolve 了**不代表模型跑成功了** ——
|
||||
* 判定交给 classifyTurnOutcome(三条互不重叠的失败信号,见那里的注释)。
|
||||
*
|
||||
* 60 秒超时算成功(与另两个插件同一取舍):长任务很正常,把它判成失败会
|
||||
* 换模型重跑一遍,等于同一封邮件跑两次。超时只是「不再等着上报结论」,
|
||||
* 会话仍在跑,轮次结束后 agent_end 会照常触发自动转发。
|
||||
*
|
||||
* 会话正在跑时走排队(返回 queued),**不能**在那种情况下判结论:
|
||||
* prompt 排完队就 resolve,此时 session.messages 里最后一条是**上一轮**的,
|
||||
* 拿它判定会把上一轮的成败当成这一轮的。
|
||||
*
|
||||
* @param {any} session
|
||||
* @param {string} promptText
|
||||
* @param {number} timeoutMs
|
||||
* @returns {Promise<{ok: boolean, error: string, aborted: boolean, timedOut: boolean, queued: boolean}>}
|
||||
*/
|
||||
export async function runTurn(session, promptText, timeoutMs = 60_000) {
|
||||
const { classifyTurnOutcome } = await import('./turn.mjs');
|
||||
|
||||
// 排队分支:模型还在说话时又来一封邮件。
|
||||
//
|
||||
// streamingBehavior 必选,缺了 prompt 直接抛
|
||||
// "Agent is already processing. Specify streamingBehavior…"。
|
||||
// 取 followUp 而不是 steer:steer 会把当前这一轮打断,
|
||||
// 而当前这一轮正在处理**上一封邮件** —— 那封邮件的发件人也在等回信。
|
||||
if (session.isStreaming) {
|
||||
await session.prompt(promptText, { streamingBehavior: 'followUp' });
|
||||
return { ok: true, error: '', aborted: false, timedOut: false, queued: true };
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
const timeout = new Promise((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => resolve({ ok: true, error: '', aborted: false, timedOut: true, queued: false }),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
|
||||
const run = session.prompt(promptText)
|
||||
.then(() => ({ ...classifyTurnOutcome({ messages: session.messages }), timedOut: false, queued: false }))
|
||||
.catch((e) => ({ ...classifyTurnOutcome({ error: e }), timedOut: false, queued: false }));
|
||||
|
||||
try {
|
||||
return await Promise.race([run, timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 续谈:往一条已经存在的会话里追加一轮。
|
||||
*
|
||||
* 这就是 `runTurn` —— 不需要第二个函数。
|
||||
*
|
||||
* **不能**用 `session.followUp()`:那个方法只往 followUpQueue 里塞消息,
|
||||
* 队列**只在运行中的轮次末尾**被 drain(pi-agent-core/agent.js 的 run 循环,
|
||||
* 以及 `continue()`)。会话空闲时(上一轮早已结束)塞进去的消息永远没人取,
|
||||
* 于是这封邮件既没有回信也没有报错 —— 实测踩过:日志打了「续谈」,
|
||||
* 收件箱里只有来信没有回复。
|
||||
*
|
||||
* `runTurn` 按 `isStreaming` 分流,两种状态都正确:
|
||||
* - 空闲 → `prompt()` 直接起一轮
|
||||
* - 正在跑 → `prompt(text, {streamingBehavior:'followUp'})` 排到当轮之后
|
||||
*/
|
||||
export { runTurn as followUpTurn };
|
||||
355
plugins/pi-mail-bridge/src/tools.mjs
Normal file
355
plugins/pi-mail-bridge/src/tools.mjs
Normal file
@ -0,0 +1,355 @@
|
||||
/**
|
||||
* 邮件工具(T-1..T-6)—— 注册给 pi 里的模型。
|
||||
*
|
||||
* pi 的工具定义用 TypeBox schema,这里直接写等价的 JSON Schema 字面量:
|
||||
* TypeBox 的 `Type.Object({...})` 产出的就是这个形状,而桥是 .mjs(无编译步骤),
|
||||
* 少一个运行时依赖。
|
||||
*
|
||||
* `execute(toolCallId, params, signal, onUpdate, ctx)` 的 ctx 是 ExtensionContext,
|
||||
* 由此可以拿到 `ctx.sessionManager.getSessionId()` —— 这就是 C-6 要求的
|
||||
* 「工具能拿到当前会话 id」,自动转发去重(B-5.3)靠它把发信记到正确的会话上。
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { basename } from 'node:path';
|
||||
import {
|
||||
renderInbox,
|
||||
idsToMarkRead,
|
||||
formatSize,
|
||||
DEFAULT_INBOX_STATUS,
|
||||
DEFAULT_INBOX_LIMIT,
|
||||
} from '../lib/inbox-format.js';
|
||||
import {
|
||||
renderNameSuggestions,
|
||||
renderPathSuggestions,
|
||||
renderSessionSuggestions,
|
||||
renderParticipants,
|
||||
renderContacts,
|
||||
renderThread,
|
||||
} from '../lib/discovery.js';
|
||||
import { noteExplicitSend } from '../lib/relay-dedup.js';
|
||||
|
||||
const text = (s) => ({ content: [{ type: 'text', text: s }] });
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {import('./gateway.mjs').GatewayClient} deps.client
|
||||
* @param {(msg: string) => void} deps.log
|
||||
* @param {string} [deps.agentName] 自己的 Agent 名。收件箱渲染靠它判定
|
||||
* 「我是收件人还是抄送方」并给出可投递地址。
|
||||
*/
|
||||
export function createMailTools({ client, log, agentName = '' }) {
|
||||
const sendMail = {
|
||||
name: 'send_mail',
|
||||
label: 'SendMail',
|
||||
description:
|
||||
'发送邮件。三维地址 name@path.session:省略 session 投递到默认会话,' +
|
||||
'.new 强制新建,.具体别名 必须已存在。回复来信请传 reply_to。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
to: { type: 'string', description: '收件人三维地址,如 admin@/home/program/x' },
|
||||
subject: { type: 'string', description: '邮件主题' },
|
||||
body: { type: 'string', description: '邮件正文(Markdown)' },
|
||||
cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' },
|
||||
reply_to: { type: 'string', description: '回复某封邮件时传其 mail_id' },
|
||||
session_alias: { type: 'string', description: '给新会话命名(仅 .new 时生效)' },
|
||||
attachment_ids: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: '附件 ID 列表(先用 upload_attachment 取得)',
|
||||
},
|
||||
},
|
||||
required: ['to', 'subject', 'body'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||||
const result = await client.post('/mail/send', {
|
||||
to: params.to,
|
||||
subject: params.subject,
|
||||
body: params.body,
|
||||
cc: params.cc || '',
|
||||
reply_to: params.reply_to || '',
|
||||
session_alias: params.session_alias || '',
|
||||
attachment_ids: params.attachment_ids || [],
|
||||
// 这里**不带 relay**(N-5):模型的自主发信要计配额,
|
||||
// 免配额通道只给插件代劳的转发(总结、权限询问、故障报告)。
|
||||
});
|
||||
// 记下「模型这一轮亲手发过信」,供 B-5.3 让位判定。
|
||||
// 会话 id 从 ctx 取:工具不知道自己被哪条会话调用,就没法正确归属。
|
||||
noteExplicitSend(ctx?.sessionManager?.getSessionId?.(), params.to, params.reply_to);
|
||||
const budget = typeof result.budget_remaining === 'number'
|
||||
? ` 本任务剩余 ${result.budget_remaining}/${result.budget_max} 个来回。`
|
||||
: '';
|
||||
return text(`邮件已发送(ID: ${result.mail_id})${budget}`);
|
||||
},
|
||||
};
|
||||
|
||||
const readInbox = {
|
||||
name: 'read_inbox',
|
||||
label: 'ReadInbox',
|
||||
description:
|
||||
'查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。' +
|
||||
'每封含 mail_id、发件人、主题、正文与附件清单(带 attachment_id)。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', description: '过滤条件 unread|all,默认 unread' },
|
||||
limit: { type: 'number', description: '返回数量,默认 5' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const status = params.status || DEFAULT_INBOX_STATUS;
|
||||
const { mails } = await client.get(
|
||||
`/mail/inbox?status=${encodeURIComponent(status)}&limit=${params.limit || DEFAULT_INBOX_LIMIT}`,
|
||||
);
|
||||
|
||||
// 渲染与已读策略走共用模块:与另两个平台必须一致,
|
||||
// 每条规则对应过一次真实的错误行为(见 lib/inbox-format.js)。
|
||||
//
|
||||
// 传 agentName 才能判定身份并给出可投递地址 —— 不传的话模型只能
|
||||
// 从抄送行里抄一个 `.new`,而那是一次性的,回过去只会再建一条平行会话。
|
||||
const listed = renderInbox(mails, 200, agentName);
|
||||
|
||||
const ids = idsToMarkRead(params.status, mails);
|
||||
if (ids.length) {
|
||||
// 标记失败不该让 read_inbox 失败:正文已经取到了,
|
||||
// 代价只是下次重复看到,比丢掉这次读取轻。
|
||||
client.post('/mail/read', { mail_ids: ids }).catch((e) =>
|
||||
log(`[pi-mail-bridge] 标记已读失败: ${e?.message || e}`));
|
||||
}
|
||||
return text(listed);
|
||||
},
|
||||
};
|
||||
|
||||
const forwardMail = {
|
||||
name: 'forward_mail',
|
||||
label: 'ForwardMail',
|
||||
description:
|
||||
'转发一封邮件给新的收件人(引用原文)。与回复不同:回复落回原会话,' +
|
||||
'转发按目标地址另行定位会话。只能转发自己参与过的邮件。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mail_id: { type: 'string', description: '要转发的邮件 ID(从 read_inbox 获得)' },
|
||||
to: { type: 'string', description: '新收件人的三维地址' },
|
||||
comment: { type: 'string', description: '转发说明,置于引用原文之前' },
|
||||
cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' },
|
||||
subject: { type: 'string', description: '自定义主题;留空则自动加 Fwd: 前缀' },
|
||||
session_alias: { type: 'string', description: '仅在目标地址以 .new 结尾时生效:给新会话命名' },
|
||||
},
|
||||
required: ['mail_id', 'to'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||||
// 路径带 mail_id(POST /mail/{id}/forward),不是请求体里的字段
|
||||
const result = await client.post(`/mail/${params.mail_id}/forward`, {
|
||||
to: params.to,
|
||||
comment: params.comment || '',
|
||||
cc: params.cc || '',
|
||||
subject: params.subject || '',
|
||||
session_alias: params.session_alias || '',
|
||||
});
|
||||
noteExplicitSend(ctx?.sessionManager?.getSessionId?.(), params.to, '');
|
||||
return text(`已转发。新 Mail ID: ${result.mail_id},Session: ${result.session_id}`);
|
||||
},
|
||||
};
|
||||
|
||||
const uploadAttachment = {
|
||||
name: 'upload_attachment',
|
||||
label: 'UploadAttachment',
|
||||
description: '上传本地文件作为邮件附件,返回 attachment_id。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: { type: 'string', description: '本地文件的绝对路径' },
|
||||
},
|
||||
required: ['file_path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const buf = await readFile(params.file_path);
|
||||
const a = await client.uploadFile(buf, basename(params.file_path) || 'file');
|
||||
return text(
|
||||
`已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const downloadAttachment = {
|
||||
name: 'download_attachment',
|
||||
label: 'DownloadAttachment',
|
||||
description: '下载邮件附件到本地文件。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
attachment_id: { type: 'string', description: '附件 ID(read_inbox 的清单里给出)' },
|
||||
save_path: { type: 'string', description: '保存路径' },
|
||||
},
|
||||
required: ['attachment_id', 'save_path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const buf = await client.downloadFile(params.attachment_id);
|
||||
await writeFile(params.save_path, buf);
|
||||
return text(`已保存到 ${params.save_path}(${formatSize(buf.length)})`);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── 寻址发现工具(读 Agent 侧只读端点)───
|
||||
//
|
||||
// 在这一组之前,send_mail 的 to 是个只能靠记忆拼写的自由文本字段,
|
||||
// 而拼错不报错:生产上另一个平台猜了 `opencode@/home`,投递成功,
|
||||
// 但那不是 opencode 的工作目录,静默变成了新会话的 workspace。
|
||||
//
|
||||
// 渲染逻辑在 lib/discovery.js(三平台共用)。
|
||||
|
||||
const suggestAddress = {
|
||||
name: 'suggest_address',
|
||||
label: 'SuggestAddress',
|
||||
description:
|
||||
'查询可用的收件人地址,用于精准发信。不带参数给候选收件人名;带 name 给它可用的' +
|
||||
'工作目录;name+path 都带则给该目录下可续谈的会话与现成地址。' +
|
||||
'**发信前应先用它确认地址**,不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: '收件人名;留空则列出所有候选收件人' },
|
||||
path: { type: 'string', description: '工作目录;与 name 同时给出才列会话' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const name = String(params.name || '').trim();
|
||||
const path = String(params.path || '').trim();
|
||||
const qs = new URLSearchParams();
|
||||
if (name) qs.set('name', name);
|
||||
if (path) qs.set('path', path);
|
||||
const data = await client.get(`/agent/contacts/suggest?${qs.toString()}`);
|
||||
// 按服务端回的 kind 分派而不是按本地参数:省略与传空串在服务端
|
||||
// 是同一个意思,但「哪一段该渲染成什么」只有服务端知道。
|
||||
switch (data?.kind) {
|
||||
case 'name': return text(renderNameSuggestions(data.suggestions));
|
||||
case 'path': return text(renderPathSuggestions(data.suggestions, name));
|
||||
default: return text(renderSessionSuggestions(data, name, path));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const listContacts = {
|
||||
name: 'list_contacts',
|
||||
label: 'ListContacts',
|
||||
description:
|
||||
'列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。' +
|
||||
'用于回答「我还有什么没处理」与「上次跟某人聊的那条线索地址是什么」。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'number', description: '最多列出多少条,默认 20' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const data = await client.get('/agent/contacts');
|
||||
return text(renderContacts(data, params.limit || 20));
|
||||
},
|
||||
};
|
||||
|
||||
const sessionParticipants = {
|
||||
name: 'session_participants',
|
||||
label: 'SessionParticipants',
|
||||
description:
|
||||
'列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,' +
|
||||
'并标出谁还没回应。**要回给抄收方或向第三方转达时先用它拿地址**。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
session_id: { type: 'string', description: '会话 ID' },
|
||||
},
|
||||
required: ['session_id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const data = await client.get(`/agent/sessions/${params.session_id}/participants`);
|
||||
return text(renderParticipants(data));
|
||||
},
|
||||
};
|
||||
|
||||
const readThread = {
|
||||
name: 'read_thread',
|
||||
label: 'ReadThread',
|
||||
description:
|
||||
'查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时' +
|
||||
'用它确认别人已经说了什么,避免重复提问或重复汇报。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mail_id: { type: 'string', description: '线索中任一封邮件的 ID' },
|
||||
offset: { type: 'number', description: '分页偏移,续取时传上次返回的 next_offset' },
|
||||
},
|
||||
required: ['mail_id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const qs = params.offset ? `?offset=${params.offset}` : '';
|
||||
const data = await client.get(`/agent/mail/${params.mail_id}/thread${qs}`);
|
||||
return text(renderThread(data, agentName));
|
||||
},
|
||||
};
|
||||
|
||||
const readMail = {
|
||||
name: 'read_mail',
|
||||
label: 'ReadMail',
|
||||
description:
|
||||
'读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。' +
|
||||
'收件箱只给摘要;要回给抄收方就得先看清这封信发给了谁。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mail_id: { type: 'string', description: '邮件 ID' },
|
||||
},
|
||||
required: ['mail_id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id, params) {
|
||||
const data = await client.get(`/agent/mail/${params.mail_id}`);
|
||||
const m = data?.mail || {};
|
||||
const lines = [
|
||||
`发件人: ${m.from_name || '?'}`,
|
||||
`收件人: ${m.to_name || '?'}${m.to_workspace ? '@' + m.to_workspace : ''}`,
|
||||
`主题: ${m.subject || '(无主题)'}`,
|
||||
`会话: #${data.session_alias || '未命名'}(session_id: ${m.session_id || '?'})`,
|
||||
];
|
||||
if (Array.isArray(m.cc_list) && m.cc_list.length) {
|
||||
lines.push(`抄送: ${m.cc_list.map(c => c?.raw || c?.name).join('、')}`);
|
||||
}
|
||||
if (Array.isArray(m.attachments) && m.attachments.length) {
|
||||
lines.push(`附件: ${m.attachments
|
||||
.map(a => `${a.filename}(${formatSize(a.size_bytes)}, id=${a.attachment_id})`)
|
||||
.join('、')}`);
|
||||
}
|
||||
lines.push('', m.body || '(空正文)', '');
|
||||
if (Array.isArray(data.participants) && data.participants.length) {
|
||||
lines.push('可投递地址: ' + data.participants
|
||||
.filter(p => p.address && p.name !== agentName)
|
||||
.map(p => `${p.address}(${p.role})`)
|
||||
.join('、'));
|
||||
}
|
||||
if (data.reply_address) {
|
||||
lines.push(`回信给发件人用 ${data.reply_address},或传 reply_to=${m.mail_id}。`);
|
||||
}
|
||||
return text(lines.join('\n'));
|
||||
},
|
||||
};
|
||||
|
||||
// 故意**没有** request_permission(N-1 / T-7):
|
||||
// 权限询问由 tool_call 钩子接管 —— 模型可能忘了调,也可能在不需要时乱调,
|
||||
// 而真正被 pi 拦下的那一次才是事实。
|
||||
return [
|
||||
sendMail, readInbox, readMail, forwardMail,
|
||||
uploadAttachment, downloadAttachment,
|
||||
// 寻址发现:让模型选地址而不是拼地址
|
||||
suggestAddress, listContacts, sessionParticipants, readThread,
|
||||
];
|
||||
}
|
||||
187
plugins/pi-mail-bridge/src/turn.mjs
Normal file
187
plugins/pi-mail-bridge/src/turn.mjs
Normal file
@ -0,0 +1,187 @@
|
||||
/**
|
||||
* pi 侧的纯逻辑:提示词、轮次结论判定、消息文本提取、回信主题。
|
||||
*
|
||||
* 单独一个文件而不是塞进 index.mjs:这几件事每一件都对应过一次真实的错误行为,
|
||||
* 而它们都不需要 pi SDK —— 因此可以直接用 node --test 钉住,不必起模型。
|
||||
*
|
||||
* 与 lib/ 的区别:lib/ 下的文件三个平台**逐字节相同**(deploy/check-shared-libs.sh
|
||||
* 校验),这里的东西是 pi 专属的(消息形状、stopReason 语义),不参与那个约束。
|
||||
*/
|
||||
|
||||
/** 去掉已有的 Re: 前缀,避免 Re: Re: Re: 叠加。 */
|
||||
export function stripRe(subject) {
|
||||
return String(subject ?? '').replace(/^(\s*Re:\s*)+/i, '');
|
||||
}
|
||||
|
||||
/** 自动转发时的回信主题。 */
|
||||
export function replySubject(subject) {
|
||||
const base = stripRe(subject).trim();
|
||||
return base ? `Re: ${base}` : '本轮工作总结';
|
||||
}
|
||||
|
||||
/**
|
||||
* 取最后一条 assistant 消息里的纯文本。
|
||||
*
|
||||
* pi 的消息形状:`{ role, content: [{ type: 'text'|'thinking'|'toolCall', ... }] }`。
|
||||
*
|
||||
* **只取 `type === 'text'`**(B-5.1):thinking 块是思考过程,转进邮件对收件人
|
||||
* 没有意义,而且经常包含「我先假设…」这类会被误读为结论的话。
|
||||
*
|
||||
* 从后往前找第一条**有文本**的 assistant 消息,而不是「最后一条 assistant 消息」:
|
||||
* 一轮的收尾常常是纯工具调用消息(content 里只有 toolCall),
|
||||
* 取到它会得到空字符串,于是 B-5.4 判成「无话可说」而漏掉真正的结论。
|
||||
*
|
||||
* @param {any[]} messages `session.messages` 或 `agent_end` 事件里的 messages
|
||||
* @returns {string} 纯文本,找不到时为空串
|
||||
*/
|
||||
export function lastAssistantText(messages) {
|
||||
const list = Array.isArray(messages) ? messages : [];
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
const m = list[i];
|
||||
if (m?.role !== 'assistant') continue;
|
||||
const blocks = Array.isArray(m.content) ? m.content : [];
|
||||
const text = blocks
|
||||
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
|
||||
.map((b) => b.text)
|
||||
.join('\n')
|
||||
.trim();
|
||||
if (text) return text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定这一轮到底跑起来了没有(C-4 / D-3)。
|
||||
*
|
||||
* 「submit 返回了」不等于「模型跑了」—— 这是两次适配都踩过的坑(契约 9.2)。
|
||||
* pi 侧有三条互不重叠的失败信号,必须全查:
|
||||
*
|
||||
* 1. `prompt()` 直接 reject。凭证缺失就是这条:实测无 API key 的 provider
|
||||
* 抛 `No API key found for amazon-bedrock.`,一个事件都不发。
|
||||
* 2. 最后一条 assistant 消息 `stopReason === 'error'`,原因在 `errorMessage`。
|
||||
* 模型请求发出去了但上游报错走这条。
|
||||
* 3. 一条 assistant 消息都没有。既没抛也没报错却什么都没产出,
|
||||
* 当成功处理会让 B-5 转发一个空字符串回去 —— 发件人收到一封空邮件。
|
||||
*
|
||||
* `stopReason: 'aborted'` **算失败**但要区别对待:那是有人主动打断
|
||||
* (Esc / dispose),不是模型故障,因此不该触发换模型重试。
|
||||
*
|
||||
* @param {{error?: any, messages?: any[]}} input
|
||||
* @returns {{ok: boolean, error: string, aborted: boolean}}
|
||||
*/
|
||||
export function classifyTurnOutcome({ error, messages } = {}) {
|
||||
if (error) {
|
||||
return { ok: false, error: describeError(error), aborted: false };
|
||||
}
|
||||
const list = Array.isArray(messages) ? messages : [];
|
||||
let lastAssistant = null;
|
||||
for (let i = list.length - 1; i >= 0; i--) {
|
||||
if (list[i]?.role === 'assistant') { lastAssistant = list[i]; break; }
|
||||
}
|
||||
if (!lastAssistant) {
|
||||
return { ok: false, error: '模型没有产出任何回复(一条 assistant 消息都没有)', aborted: false };
|
||||
}
|
||||
const stop = lastAssistant.stopReason;
|
||||
if (stop === 'error') {
|
||||
return {
|
||||
ok: false,
|
||||
error: describeError(lastAssistant.errorMessage) || '模型报错但未给出原因',
|
||||
aborted: false,
|
||||
};
|
||||
}
|
||||
if (stop === 'aborted') {
|
||||
return { ok: false, error: '本轮被中断(aborted)', aborted: true };
|
||||
}
|
||||
// 'stop' 正常收尾;'length' 是被 max tokens 截断 —— 内容不完整但**是模型的产出**,
|
||||
// 判成失败会让一封「说了一半」的回信变成「换个模型重试」,那更糟。
|
||||
// 'toolUse' 出现在这里说明轮次在等工具,正常流程下 agent_end 时不会是它。
|
||||
return { ok: true, error: '', aborted: false };
|
||||
}
|
||||
|
||||
/** 把各种形态的错误拼成一行可读文本。 */
|
||||
export function describeError(err) {
|
||||
if (!err) return '';
|
||||
if (typeof err === 'string') return err.split('\n')[0].trim();
|
||||
const parts = [err.code, err.message ?? String(err)].filter(Boolean);
|
||||
return parts.join(': ').split('\n')[0].trim() || '未知错误';
|
||||
}
|
||||
|
||||
/**
|
||||
* 投递一封邮件时给模型的提示词。
|
||||
*
|
||||
* 三条硬要求(B-3.4 / B-3.5):
|
||||
* - 写明「回信由插件自动发」。不说的话模型会自己调 send_mail,
|
||||
* 而插件在轮次结束时也会转发一次 —— 同一件事两封邮件(生产里真实发生过)。
|
||||
* - 带上 mail_id,让模型能自己定位这一封。
|
||||
* - 让它先调 read_inbox:事件里只有主题,正文和附件清单都在收件箱里。
|
||||
*
|
||||
* @param {{agentName: string, data: any, kind: string, reused: boolean}} input
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildMailPrompt({ agentName, data, kind, reused }) {
|
||||
if (kind === 'permission') {
|
||||
return [
|
||||
`你之前发起的权限请求已有结论:${data?.decision ?? '(未给出)'}` +
|
||||
`(决策人:${data?.decided_by || '用户'})。`,
|
||||
`请据此继续后续工作。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const head = reused
|
||||
? '本会话收到一封新邮件(AgentMail 续谈)。'
|
||||
: '你收到一封新邮件(AgentMail)。';
|
||||
const lines = [
|
||||
head,
|
||||
'',
|
||||
`发件人:${data?.from_name || 'unknown'}`,
|
||||
`主题:${data?.subject || '(无主题)'}`,
|
||||
`邮件 ID:${data?.mail_id || 'unknown'}`,
|
||||
];
|
||||
if (!reused) lines.push(`身份:你是 ${agentName}`);
|
||||
// 服务端算好的回信地址(`new_mail` 的 reply_address)。带上它是因为模型
|
||||
// **确实会**自己发信 —— 尤其是要抄送第三方、或分多封交代不同的事时。
|
||||
// 让它自己拼三维地址的话,`.new` 会被拼进去,于是回信静默开出一条新会话,
|
||||
// 原来的线索里再无下文。
|
||||
if (data?.reply_address) {
|
||||
lines.push(`回信地址:${data.reply_address}(如需自己发信,用这个地址)`);
|
||||
}
|
||||
if (data?.catchup) {
|
||||
// 补投的邮件要说明,否则模型会以为这是刚到的、按「立即响应」的语气回
|
||||
lines.push('说明:这是插件离线期间积压的邮件,现在补投给你。');
|
||||
}
|
||||
lines.push(
|
||||
'',
|
||||
'请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),',
|
||||
'然后处理其中的请求。',
|
||||
'回信不用你自己发:把这一轮做完、把结论说出来就行,插件会把你最后那段话作为回信发出去。',
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动转发的幂等键(W-6 / B-5.2)。
|
||||
*
|
||||
* 用 pi 侧的会话 id + 会话树叶子条目 id:两者都由 pi 生成且落盘,
|
||||
* 插件重启后重放同一轮也会得到同一个键。用「消息条数」之类的派生量不行 ——
|
||||
* 压缩(compaction)会改变条数,于是同一轮结论换了个键,被当成新消息再转一次。
|
||||
*
|
||||
* @param {string} piSessionId
|
||||
* @param {string} leafId
|
||||
* @returns {string}
|
||||
*/
|
||||
export function relayKeyFor(piSessionId, leafId) {
|
||||
return `${piSessionId || 'unknown'}:${leafId || 'noleaf'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* pi 会话文件名里的 cwd 编码(`/home/x` → `--home-x--`)。
|
||||
*
|
||||
* 只用于日志与排查提示,不参与任何决策 —— 真正的路径一律用 SDK 给的
|
||||
* `session.sessionFile`。自己拼路径去读会话文件是错的:编码规则属于 pi。
|
||||
*
|
||||
* @param {string} cwd
|
||||
* @returns {string}
|
||||
*/
|
||||
export function sessionDirLabel(cwd) {
|
||||
return `--${String(cwd ?? '').replace(/\//g, '-')}--`;
|
||||
}
|
||||
Reference in New Issue
Block a user