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:
2026-09-03 12:09:12 +08:00
parent 22ddb1b89c
commit e6fd2fafdc
81 changed files with 11355 additions and 122 deletions

View File

@ -1,23 +1,42 @@
#!/usr/bin/env bash
# 共用模块必须逐字节相同 —— 见 docs/PLUGIN-CONTRACT.md 第六节。
#
# 一侧改了另一侧没改,个平台的行为就会悄悄分叉:同一封邮件在 opencode 那边
# 一侧改了另一侧没改,个平台的行为就会悄悄分叉:同一封邮件在 opencode 那边
# 标了已读、在 DSH 那边没标,而两处代码看起来都"对"。
#
# 三方比对以 opencode 为基准逐个对比,而不是两两对比:后者在三方都不同时
# 会打出三条互相矛盾的差异,读的人无从判断谁是对的。
set -euo pipefail
A=plugins/opencode-mail-bridge
B=plugins/dsh-mail-bridge
BASE=plugins/opencode-mail-bridge
PEERS=(plugins/dsh-mail-bridge plugins/pi-mail-bridge)
fail=0
for f in relay-dedup inbox-format session-snapshot workspace model-scope catchup; do
if ! diff -q "$A/lib/$f.js" "$B/lib/$f.js" >/dev/null 2>&1; then
echo "共用模块已分叉lib/$f.js" >&2
diff "$A/lib/$f.js" "$B/lib/$f.js" | head -20 >&2
fail=1
fi
for peer in "${PEERS[@]}"; do
for f in relay-dedup inbox-format session-snapshot workspace model-scope catchup addressing discovery; do
if [[ ! -f "$peer/lib/$f.js" ]]; then
echo "共用模块缺失:$peer/lib/$f.js" >&2
fail=1
continue
fi
if ! diff -q "$BASE/lib/$f.js" "$peer/lib/$f.js" >/dev/null 2>&1; then
echo "共用模块已分叉lib/$f.js$BASE vs $peer" >&2
diff "$BASE/lib/$f.js" "$peer/lib/$f.js" | head -20 >&2
fail=1
fi
done
# 测试同样要同源:共用模块的行为约定写在测试里,
# 只同步实现不同步测试,等于允许一侧偷偷放宽约定。
for f in inbox-format session-snapshot workspace model-scope catchup addressing discovery; do
if [[ ! -f "$peer/test/$f.test.mjs" ]]; then
echo "共用测试缺失:$peer/test/$f.test.mjs" >&2
fail=1
continue
fi
if ! diff -q "$BASE/test/$f.test.mjs" "$peer/test/$f.test.mjs" >/dev/null 2>&1; then
echo "共用测试已分叉test/$f.test.mjs$BASE vs $peer" >&2
fail=1
fi
done
done
for f in inbox-format session-snapshot workspace model-scope catchup; do
if ! diff -q "$A/test/$f.test.mjs" "$B/test/$f.test.mjs" >/dev/null 2>&1; then
echo "共用测试已分叉test/$f.test.mjs" >&2
fail=1
fi
done
[[ $fail -eq 0 ]] && echo " 共用模块两侧同源" || exit 1
[[ $fail -eq 0 ]] && echo " 共用模块三方同源opencode / dsh / pi" || exit 1

View File

@ -1,6 +1,6 @@
#!/usr/bin/env bash
#
# 把 AgentMail Gateway 与 opencode serve 安装为 systemd 服务。
# 把 AgentMail Gateway 与各平台的 mail-bridge 安装为 systemd 服务。
#
# sudo ./deploy/install.sh
#
@ -19,8 +19,8 @@ echo "==> 构建前端"
( cd "$REPO/web" && npm run typecheck && npm test && npm run build )
echo "==> 校验插件共用模块同源"
# lib/ 下的纯函数模块在个插件里逐字节相同(见 docs/PLUGIN-CONTRACT.md 第六节)。
# 一侧改了另一侧没改,个平台的行为就会悄悄分叉。
# lib/ 下的纯函数模块在个插件里逐字节相同(见 docs/PLUGIN-CONTRACT.md 第六节)。
# 一侧改了另一侧没改,个平台的行为就会悄悄分叉。
"$REPO/deploy/check-shared-libs.sh"
# 插件的纯函数测试(自动转发去重等)。
@ -45,6 +45,20 @@ else
( cd "$REPO/plugins/dsh-mail-bridge" \
&& npm install --no-audit --no-fund && npx tsc && npm test )
fi
# pi 插件:纯 ESM无构建步骤唯一的外部依赖是全局装的 pi SDK。
#
# 它不 npm install@earendil-works/pi-coding-agent 是全局包peerDependency
# 装在项目里会得到第二份 SDK两份各自维护 ~/.pi/agent 的会话索引缓存。
# 因此这里只软链一次SDK 不在时跳过测试turn/naming 是纯函数,
# 但 lib 的测试也一起跑,没必要为缺 SDK 的机器留半套门禁)。
PI_SDK=/usr/lib/node_modules/@earendil-works/pi-coding-agent
if [[ -d "$PI_SDK" ]]; then
install -d "$REPO/plugins/pi-mail-bridge/node_modules/@earendil-works"
ln -sfn "$PI_SDK" "$REPO/plugins/pi-mail-bridge/node_modules/@earendil-works/pi-coding-agent"
( cd "$REPO/plugins/pi-mail-bridge" && npm test )
else
echo " 未找到 pi SDK$PI_SDK),跳过 pi 插件测试"
fi
echo "==> 前端产物嵌入 Gateway"
# 只清构建产物,不能 rm -rf 整个目录:
@ -110,9 +124,37 @@ else
echo " $ETC/opencode.env 已存在,保留不动"
fi
if [[ ! -f "$ETC/pi.env" ]]; then
cat > "$ETC/pi.env" <<EOF
# pi mail-bridge 插件配置
AGENTMAIL_GATEWAY_URL=http://127.0.0.1:8180
AGENTMAIL_AGENT_NAME=pi
# 接入密钥:留空时插件会在 \$AGENTMAIL_CONFIG_DIR/agent.key 本地生成一把并打印到日志,
# 拿着它到 Web 后台「Agent 密钥」登记即可接入journalctl -u pi-mail-bridge | grep 密钥)。
AGENTMAIL_AGENT_KEY=
AGENTMAIL_CONFIG_DIR=/opt/agentmail/pi-config
# 处理来信时首选的模型。留空表示用 pi 自己的默认(~/.pi/agent/settings.json
# 管理员在后台划定模型范围时范围优先于这里的设置B-2.2)。
AGENTMAIL_REPLY_PROVIDER=llmsproxy
AGENTMAIL_REPLY_MODEL=AUTO
# 单轮上限。超时**不算失败**pi 仍在后台跑,转发挂在 agent_end 上,
# 说完了自然会转出去。这个值只决定「多久之后不再阻塞投递流水线」。
AGENTMAIL_TURN_TIMEOUT_MS=60000
EOF
chmod 0600 "$ETC/pi.env"
install -d -m 0700 "$PREFIX/pi-config"
echo " 已生成 $ETC/pi.env接入密钥首启时本地生成"
else
echo " $ETC/pi.env 已存在,保留不动"
fi
echo "==> 安装 systemd 单元"
install -m 0644 "$REPO/deploy/agentmail-gateway.service" /etc/systemd/system/
install -m 0644 "$REPO/deploy/opencode-serve.service" /etc/systemd/system/
install -m 0644 "$REPO/deploy/pi-mail-bridge.service" /etc/systemd/system/
systemctl daemon-reload
echo "==> 启用并启动"
@ -122,6 +164,11 @@ if command -v opencode >/dev/null 2>&1; then
else
echo " 未找到 opencode跳过 opencode-serve装好后执行systemctl enable --now opencode-serve"
fi
if [[ -d "$PI_SDK" ]]; then
systemctl enable --now pi-mail-bridge.service
else
echo " 未找到 pi SDK跳过 pi-mail-bridge装好后执行systemctl enable --now pi-mail-bridge"
fi
sleep 3
echo

View File

@ -0,0 +1,37 @@
[Unit]
Description=pi mail-bridge (AgentMail ↔ @earendil-works/pi-coding-agent)
After=network-online.target agentmail-gateway.service
Wants=network-online.target
[Service]
Type=simple
# 桥自己不需要工作目录 —— 每条会话的 cwd 来自邮件寻址的 path 位。
# 但 systemd 要求一个存在的目录,且 pi 的 SettingsManager 会在这里找
# 项目级配置,因此指向仓库而不是 /。
WorkingDirectory=/home/program/agentmail/plugins/pi-mail-bridge
ExecStart=/usr/bin/node /home/program/agentmail/plugins/pi-mail-bridge/src/index.mjs
# pi 靠 HOME 定位 ~/.pi/agentsettings.json、auth.json、models.json、sessions/)。
# systemd 不会自动注入 HOME不显式给就
# - 读不到 provider 凭证 → 每封邮件都"没有可用模型"
# - 会话落到 /.pi 或直接失败 → C-8 的会话快照永远是空的
Environment=HOME=/root
# pi 的全局扩展 pi-a2a / pi-acp 绑死 127.0.0.1:12010 / 12011。
# 桥用 noExtensions:true 起会话,本进程不会去 bind 那两个端口;
# 这两个变量是给「同机还跑着 pi CLI」的情况留的隔离位
# 万一将来放开扩展加载,端口也不会和交互式 pi 撞。
Environment=PI_A2A_PORT=13010
Environment=PI_ACP_PORT=13011
EnvironmentFile=/etc/agentmail/pi.env
Restart=always
RestartSec=10
# 桥是长驻守护进程,启动即注册 + 立刻打一次心跳 + 订阅 SSEB-1
# 不像 opencode 那样惰加载,因此不需要 ExecStartPost 预热。
[Install]
WantedBy=multi-user.target