@yuanchilin/dsh-mailbox 0.0.1 → 0.0.2
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 +21 -4
- package/bin/mailbox.mjs +25 -2
- package/lib/client.js +16 -5
- package/lib/command.js +14 -7
- package/lib/core.js +251 -21
- package/lib/index.js +61 -6
- package/lib/types/index.d.ts +13 -1
- package/lib/watcher.js +87 -28
- package/package.json +1 -1
- package/skill/README.md +16 -0
- package/skill/SKILL.md +16 -0
- package/skill/mailbox.mjs +106 -14
- package/skill/mailbox.psm1 +93 -14
package/lib/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// ============================================================================
|
|
2
2
|
// @yuanchilin/dsh-mailbox — DeepSeek Harness cordis 插件
|
|
3
3
|
//
|
|
4
|
-
// 注册
|
|
4
|
+
// 注册 7 个模型面向工具 + 1 个宿主命令:
|
|
5
5
|
// mailbox_send 发送消息 (to=identity / 别名 / sessionId / all)
|
|
6
6
|
// mailbox_recv 读取新消息 (自动 seen 去重)
|
|
7
|
-
// mailbox_status 身份/目录/消息数 +
|
|
7
|
+
// mailbox_status 身份/目录/消息数 + 会话目录与在线状态, 输出末尾附命令一览
|
|
8
8
|
// mailbox_sessions 会话目录: 找"要对话的会话" (身份/别名/在线/未读)
|
|
9
9
|
// mailbox_alias 给本会话设置唯一别名 (便于他人定向发送)
|
|
10
10
|
// mailbox_clean 按 TTL 清理自己发过的旧消息
|
|
11
|
+
// mailbox_reset 清空整个信箱根目录 (消息+seen+注册表, 需 confirm=true, 不可恢复)
|
|
11
12
|
// /mailbox 宿主命令: /mailbox <目标|别名|all> <消息> (聊天框直发)
|
|
12
13
|
//
|
|
13
14
|
// 身份模型 (v1.1):
|
|
@@ -48,6 +49,7 @@ const Config = z.object({
|
|
|
48
49
|
patchRoot: z.string().default(""),
|
|
49
50
|
presenceWindowSec: z.number().default(300),
|
|
50
51
|
watcher: z.boolean().default(true),
|
|
52
|
+
wakeRetrySec: z.number().default(300),
|
|
51
53
|
});
|
|
52
54
|
|
|
53
55
|
const text = (s) => [{ type: "text", text: s }];
|
|
@@ -72,7 +74,7 @@ function apply(ctx, config) {
|
|
|
72
74
|
|
|
73
75
|
ctx.tools.register(defineTool({
|
|
74
76
|
name: "mailbox_send",
|
|
75
|
-
description: "通过共享文件系统信箱向其他会话/agent 发送一条异步消息。to=参与者 identity / 别名 / 完整 sessionId 定向发送,或 all 广播;消息类型 request/response/notify/reply;对方不在线也不丢消息(对方之后 recv 或 CLI wait/poll
|
|
77
|
+
description: "通过共享文件系统信箱向其他会话/agent 发送一条异步消息。to=参与者 identity / 别名 / 完整 sessionId 定向发送,或 all 广播;消息类型 request/response/notify/reply;对方不在线也不丢消息(对方之后 recv 或 CLI wait/poll 即可收到)。request 型消息对方收取时会自动回执 delivered,处理完会回 done/error(本端下次 mailbox_recv 可读到该 reply);需要对方确认后再执行的任务请用 request 型。可用 mailbox_sessions 查看有哪些会话及其身份/别名。",
|
|
76
78
|
parameters: {
|
|
77
79
|
to: { type: "string", required: true, description: "接收方:identity(如 dsh-mailbox-17cbcfa0)/ 别名(如 rp)/ 完整 sessionId / all 广播" },
|
|
78
80
|
type: { type: "string", enum: ["request", "response", "notify", "reply"], description: "消息类型,默认 notify" },
|
|
@@ -132,7 +134,7 @@ function apply(ctx, config) {
|
|
|
132
134
|
|
|
133
135
|
ctx.tools.register(defineTool({
|
|
134
136
|
name: "mailbox_recv",
|
|
135
|
-
description: "读取信箱中发给本会话的新消息(自动记录 seen
|
|
137
|
+
description: "读取信箱中发给本会话的新消息(自动记录 seen,重复调用不会重复返回)。request 型消息会向发送方自动回执 delivered(收到确认),收到此类消息请先确认意图,涉及执行/计划类任务先回复确认方案并等待发送方确认后再动手,处理完成后回执 done/error(mailbox_send to=发送方 type=reply replyTo=消息id payload={status:\"done\"})。返回消息列表:from/to/type/topic/payload/reply_to。无新消息时返回空列表。长驻等待请用 CLI:npx mailbox wait。",
|
|
136
138
|
parameters: {
|
|
137
139
|
format: { type: "string", enum: ["table", "json"], description: "输出格式,默认 table" },
|
|
138
140
|
},
|
|
@@ -160,6 +162,7 @@ function apply(ctx, config) {
|
|
|
160
162
|
},
|
|
161
163
|
},
|
|
162
164
|
},
|
|
165
|
+
ackHint: { type: "string" },
|
|
163
166
|
},
|
|
164
167
|
},
|
|
165
168
|
render: (_args, value) => {
|
|
@@ -168,12 +171,23 @@ function apply(ctx, config) {
|
|
|
168
171
|
const p = m.payload && Object.keys(m.payload).length ? ` payload=${JSON.stringify(m.payload)}` : "";
|
|
169
172
|
return `[${m.from} -> ${m.to}] ${m.type} topic=${m.topic} id=${m.id}${m.reply_to ? ` reply_to=${m.reply_to}` : ""}${p}`;
|
|
170
173
|
});
|
|
171
|
-
|
|
174
|
+
const hint = value.ackHint ? `\n${value.ackHint}` : "";
|
|
175
|
+
return text(`新消息 ${value.count} 条:\n${lines.join("\n")}${hint}`);
|
|
172
176
|
},
|
|
173
177
|
},
|
|
174
178
|
execute: async (args, exec) => withSession(cfg, exec, (eff) => {
|
|
175
179
|
const messages = core.recvNew(eff, true);
|
|
176
|
-
|
|
180
|
+
let acked = 0;
|
|
181
|
+
for (const m of messages) {
|
|
182
|
+
if (m.type === "request" && core.sendAck(eff, m, "delivered")) acked++;
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
count: messages.length,
|
|
186
|
+
messages,
|
|
187
|
+
ackHint: acked > 0
|
|
188
|
+
? `已自动回执 delivered 给 ${acked} 条 request 的发送方 ✓\n处理完成后请回报: mailbox_send to=<发送方> type=reply replyTo=<消息id> payload={status:"done"|"error"}`
|
|
189
|
+
: "",
|
|
190
|
+
};
|
|
177
191
|
}),
|
|
178
192
|
presentCall: () => ({ card: "generic", title: "mailbox recv", kind: "other" }),
|
|
179
193
|
}));
|
|
@@ -213,6 +227,8 @@ function apply(ctx, config) {
|
|
|
213
227
|
} else {
|
|
214
228
|
lines.push("会话目录: (暂无注册会话, 各会话调用一次 mailbox 工具即登记)");
|
|
215
229
|
}
|
|
230
|
+
lines.push("");
|
|
231
|
+
lines.push("可用命令: mailbox_send 发送 / mailbox_recv 收信 / mailbox_sessions 会话目录 / mailbox_alias 别名 / mailbox_clean 清理 / mailbox_reset ⚠️清空整个信箱(需 confirm=true, 不可恢复) / /mailbox <目标> <消息> 聊天框直发");
|
|
216
232
|
return text(lines.join("\n"));
|
|
217
233
|
},
|
|
218
234
|
},
|
|
@@ -314,6 +330,45 @@ function apply(ctx, config) {
|
|
|
314
330
|
}),
|
|
315
331
|
presentCall: () => ({ card: "generic", title: "mailbox clean", kind: "other" }),
|
|
316
332
|
}));
|
|
333
|
+
|
|
334
|
+
ctx.tools.register(defineTool({
|
|
335
|
+
name: "mailbox_reset",
|
|
336
|
+
description: "清空整个信箱根目录:所有会话的消息文件 (msg_*.json)、已读标记 (.seen/ 与旧 .seen.json)、会话注册表 (_sessions/ 含别名)。⚠️ 破坏性且不可恢复(全会话共享数据),confirm 必须为 true 才执行;各会话下次调用 mailbox 工具会自动重新登记身份。用于彻底清理(如回执风暴残留)或重建干净信箱。",
|
|
337
|
+
parameters: {
|
|
338
|
+
confirm: { type: "boolean", required: true, description: "必须为 true 才会真正执行" },
|
|
339
|
+
},
|
|
340
|
+
output: {
|
|
341
|
+
schema: {
|
|
342
|
+
type: "object",
|
|
343
|
+
additionalProperties: false,
|
|
344
|
+
properties: {
|
|
345
|
+
confirm: { type: "boolean", required: true },
|
|
346
|
+
removed: {
|
|
347
|
+
type: "object",
|
|
348
|
+
required: true,
|
|
349
|
+
additionalProperties: true,
|
|
350
|
+
properties: {
|
|
351
|
+
messages: { type: "integer" },
|
|
352
|
+
seen: { type: "integer" },
|
|
353
|
+
registry: { type: "integer" },
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
render: (_args, value) => {
|
|
359
|
+
if (!value.confirm) return text("(未执行:需要 confirm=true,防止误清全会话共享数据)");
|
|
360
|
+
const r = value.removed;
|
|
361
|
+
return text(`已清空信箱: 消息 ${r.messages} 条, seen ${r.seen} 条, 注册表 ${r.registry} 条\n(各会话下次调用 mailbox 工具会自动重新登记身份/别名)`);
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
execute: async (args, exec) => withSession(cfg, exec, (eff) => {
|
|
365
|
+
if (args.confirm !== true) {
|
|
366
|
+
return { confirm: false, removed: { messages: 0, seen: 0, registry: 0 } };
|
|
367
|
+
}
|
|
368
|
+
return { confirm: true, removed: core.resetMailbox(eff) };
|
|
369
|
+
}),
|
|
370
|
+
presentCall: (args) => ({ card: "generic", title: "mailbox reset", kind: "danger", rawInput: args }),
|
|
371
|
+
}));
|
|
317
372
|
}
|
|
318
373
|
|
|
319
374
|
export { Config, apply, inject, name };
|
package/lib/types/index.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface MailboxConfig {
|
|
|
11
11
|
patchRoot: string;
|
|
12
12
|
presenceWindowSec: number;
|
|
13
13
|
watcher: boolean;
|
|
14
|
+
wakeRetrySec: number;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export interface MailboxMessage {
|
|
@@ -65,10 +66,21 @@ export function resolveDirs(cfg: MailboxConfig): { out: string; in: string[] };
|
|
|
65
66
|
export function seenFileOf(cfg: MailboxConfig): string;
|
|
66
67
|
export function loadSeen(cfg: MailboxConfig): string[];
|
|
67
68
|
export function saveSeen(cfg: MailboxConfig, seen: string[]): void;
|
|
69
|
+
export function markSeen(cfg: MailboxConfig, id: string): void;
|
|
70
|
+
export function isSeen(cfg: MailboxConfig, id: string): boolean;
|
|
71
|
+
|
|
72
|
+
// ---- 回执协议 (ack) ----
|
|
73
|
+
export const ACK_TOPIC: "status";
|
|
74
|
+
export function hasAck(cfg: MailboxConfig, requestId: string, status: string): boolean;
|
|
75
|
+
export function sendAck(cfg: MailboxConfig, request: MailboxMessage, status: string, extra?: Record<string, unknown>): boolean;
|
|
76
|
+
export interface MailboxReplyStatus { ts: number; status: string; id: string; from: string; payload?: Record<string, unknown>; }
|
|
77
|
+
export function latestReplyStatus(cfg: MailboxConfig, requestId: string, expectFrom?: string): MailboxReplyStatus | null;
|
|
68
78
|
export function sendMessage(cfg: MailboxConfig, msg: Partial<Pick<MailboxMessage, "to" | "type" | "topic" | "payload" | "reply_to">>): string;
|
|
69
|
-
export function recvNew(cfg: MailboxConfig,
|
|
79
|
+
export function recvNew(cfg: MailboxConfig, mark?: boolean): MailboxMessage[];
|
|
70
80
|
export function removeMessage(cfg: MailboxConfig, id: string, inbox?: boolean): boolean;
|
|
71
81
|
export function cleanTTL(cfg: MailboxConfig, opts?: { ttlHours?: number; dryRun?: boolean }): number;
|
|
82
|
+
export interface MailboxResetResult { messages: number; seen: number; registry: number; }
|
|
83
|
+
export function resetMailbox(cfg: MailboxConfig): MailboxResetResult;
|
|
72
84
|
export function statusOf(cfg: MailboxConfig): MailboxStatus;
|
|
73
85
|
export function assertUsable(cfg: MailboxConfig): void;
|
|
74
86
|
|
package/lib/watcher.js
CHANGED
|
@@ -14,15 +14,42 @@
|
|
|
14
14
|
// 任何轮询异常吞掉下轮重试, 绝不抛向启动流程。
|
|
15
15
|
// ============================================================================
|
|
16
16
|
|
|
17
|
-
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
17
|
+
import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, statSync, rmSync, renameSync } from "node:fs";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import * as core from "./core.js";
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* 纯检测逻辑: 扫描 root 下参与者目录 (跳过 _/. 前缀), 找出 live 身份未 seen
|
|
23
|
-
* 的新消息。known (Set
|
|
23
|
+
* 的新消息。known (Set<绝对路径+收件人>) 就地更新; 返回 Map<identity, msg[]>。
|
|
24
24
|
*/
|
|
25
|
-
|
|
25
|
+
function wakeMarkerPath(root, identity, messageId) {
|
|
26
|
+
return join(root, identity, ".wake", `${encodeURIComponent(messageId)}.json`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function recentlyWoken(root, identity, messageId, retrySec) {
|
|
30
|
+
if (!(retrySec > 0)) return false;
|
|
31
|
+
try {
|
|
32
|
+
return Date.now() - statSync(wakeMarkerPath(root, identity, messageId)).mtimeMs < retrySec * 1000;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function markWoken(root, identity, messageId) {
|
|
39
|
+
const dir = join(root, identity, ".wake");
|
|
40
|
+
mkdirSync(dir, { recursive: true });
|
|
41
|
+
const file = wakeMarkerPath(root, identity, messageId);
|
|
42
|
+
const temp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
43
|
+
writeFileSync(temp, JSON.stringify({ id: messageId, ts: Date.now() }) + "\n", "utf-8");
|
|
44
|
+
rmSync(file, { force: true });
|
|
45
|
+
renameSync(temp, file);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function messagePath(root, message) {
|
|
49
|
+
return join(root, message.from, `msg_${message.id}.json`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function scanMailboxRoot(root, known, live, { wakeRetrySec = 0 } = {}) {
|
|
26
53
|
const fresh = new Map();
|
|
27
54
|
if (!existsSync(root)) return fresh;
|
|
28
55
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
@@ -37,31 +64,37 @@ export function scanMailboxRoot(root, known, live) {
|
|
|
37
64
|
for (const f of files) {
|
|
38
65
|
if (!f.startsWith("msg_") || !f.endsWith(".json")) continue;
|
|
39
66
|
const filePath = join(dirPath, f);
|
|
40
|
-
if (known.has(filePath)) continue;
|
|
41
|
-
known.add(filePath);
|
|
42
67
|
let m;
|
|
43
68
|
try {
|
|
44
69
|
m = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
45
70
|
} catch {
|
|
46
|
-
continue; //
|
|
71
|
+
continue; // 半写消息留待下轮重试
|
|
47
72
|
}
|
|
48
73
|
for (const identity of live.keys()) {
|
|
74
|
+
const knownKey = `${filePath}\0${identity}`;
|
|
75
|
+
if (known.has(knownKey) || known.has(filePath)) continue;
|
|
49
76
|
if (m.to !== identity && m.to !== "all") continue;
|
|
77
|
+
if (recentlyWoken(root, identity, m.id, wakeRetrySec)) {
|
|
78
|
+
known.add(knownKey);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
50
81
|
// 自收消息 (from=自己): recv 只扫别人目录, 永远读不到 → 不唤醒 (否则每次重启重复响铃)
|
|
51
82
|
if (m.from === identity) continue;
|
|
52
83
|
// 收件人已处理 (seen 含该 id) 则不重复唤醒
|
|
53
|
-
|
|
54
|
-
if (existsSync(
|
|
84
|
+
// seen 目录化: <identity>/.seen/<id>.seen (原子写); 兼容旧单文件 .seen.json
|
|
85
|
+
if (existsSync(join(root, identity, ".seen", `${m.id}.seen`))) continue;
|
|
86
|
+
const legacySeen = join(root, identity, ".seen.json");
|
|
87
|
+
if (existsSync(legacySeen)) {
|
|
55
88
|
try {
|
|
56
|
-
const v = JSON.parse(readFileSync(
|
|
57
|
-
|
|
58
|
-
if (seen.includes(m.id)) continue;
|
|
89
|
+
const v = JSON.parse(readFileSync(legacySeen, "utf-8"));
|
|
90
|
+
if ((Array.isArray(v) ? v : [v]).includes(m.id)) continue;
|
|
59
91
|
} catch {
|
|
60
92
|
// seen 损坏则视为未处理, 照常唤醒
|
|
61
93
|
}
|
|
62
94
|
}
|
|
63
95
|
if (!fresh.has(identity)) fresh.set(identity, []);
|
|
64
96
|
fresh.get(identity).push(m);
|
|
97
|
+
known.add(knownKey);
|
|
65
98
|
}
|
|
66
99
|
}
|
|
67
100
|
}
|
|
@@ -93,28 +126,54 @@ export function startMailboxWatcher(ctx, cfg) {
|
|
|
93
126
|
}
|
|
94
127
|
}
|
|
95
128
|
if (live.size === 0) return;
|
|
96
|
-
const fresh = scanMailboxRoot(cfg.root, known, live);
|
|
129
|
+
const fresh = scanMailboxRoot(cfg.root, known, live, { wakeRetrySec: cfg.wakeRetrySec });
|
|
97
130
|
for (const [identity, messages] of fresh) {
|
|
98
131
|
const agent = live.get(identity);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
132
|
+
// 投递回执 (watcher 层): 系统识别到"发往在线会话的新消息"即视为已投递,
|
|
133
|
+
// 代接收方立刻回 delivered —— 不依赖对方 agent 是否来 recv/是否回应。
|
|
134
|
+
// delivered 语义 = "系统已转交到对方会话"; 对方读没读/办没办由后续
|
|
135
|
+
// done/error 或人工跟进确认。幂等: sendAck 内部 hasAck 防重复回执。
|
|
136
|
+
const recvCfg = { ...cfg, identity };
|
|
137
|
+
let started = false;
|
|
138
|
+
try {
|
|
139
|
+
const isRequest = messages.some((m) => m.type === "request");
|
|
140
|
+
const ids = messages.map((m) => m.id).join(",");
|
|
141
|
+
const policy = isRequest
|
|
142
|
+
? `其中包含 request 型消息: 请 mailbox_recv 读取 (已回执 delivered)。涉及执行/计划类任务, 先向消息发送方回复确认方案并等待确认后再动手; 完成后回执 done/error。`
|
|
143
|
+
: `请调用 mailbox_recv 读取并处理这些消息。`;
|
|
144
|
+
jobs.start({
|
|
145
|
+
kind: "mailbox",
|
|
146
|
+
label: `mailbox 新消息: ${messages.length} 条 -> ${identity}`,
|
|
147
|
+
owner: agent,
|
|
148
|
+
run: () => ({
|
|
149
|
+
cancel: () => {},
|
|
150
|
+
done: Promise.resolve({
|
|
151
|
+
status: "completed",
|
|
152
|
+
detail: `[${identity}] ${messages.length} 条 mailbox 消息`,
|
|
153
|
+
output: `收到 ${messages.length} 条 mailbox 消息 ids=${ids}\n${policy}`,
|
|
112
154
|
}),
|
|
113
|
-
})
|
|
114
|
-
}
|
|
115
|
-
|
|
155
|
+
}),
|
|
156
|
+
});
|
|
157
|
+
started = true;
|
|
158
|
+
for (const m of messages) {
|
|
159
|
+
const knownKey = `${messagePath(cfg.root, m)}\0${identity}`;
|
|
160
|
+
known.add(knownKey);
|
|
161
|
+
// 回执迷走防御: 回执自身不再代回 delivered, 防止无限互回。
|
|
162
|
+
if (m.type === "reply" || m.topic === "status") continue;
|
|
163
|
+
try {
|
|
164
|
+
core.sendAck(recvCfg, m, "delivered");
|
|
165
|
+
} catch {
|
|
166
|
+
// 回执失败不影响唤醒
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const m of messages) {
|
|
170
|
+
try { markWoken(cfg.root, identity, m.id); } catch { /* 标记失败不影响本次唤醒 */ }
|
|
116
171
|
}
|
|
172
|
+
} catch {
|
|
173
|
+
// job 创建失败时撤销精确 key, 允许下一轮重试
|
|
174
|
+
for (const m of messages) known.delete(`${messagePath(cfg.root, m)}\0${identity}`);
|
|
117
175
|
}
|
|
176
|
+
if (!started) continue;
|
|
118
177
|
}
|
|
119
178
|
} catch {
|
|
120
179
|
// 轮询异常静默, 下轮重试
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yuanchilin/dsh-mailbox",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "DeepSeek Harness cross-session file mailbox: send/recv/status tools over a shared filesystem mailbox (N participants, directed & broadcast routing, seen dedup, TTL cleanup, session directory & presence), a /mailbox slash command with popup completion, a plugin-native wake watcher, a zero-dependency node CLI and a packaged DSH skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
package/skill/README.md
CHANGED
|
@@ -51,6 +51,22 @@ node mailbox.mjs wait --timeout 600
|
|
|
51
51
|
| clean | `clean [-TtlHours n] [-DryRun]` | `clean [--ttl-hours n] [--dry-run]` | TTL 清理自己的已发送消息 |
|
|
52
52
|
| status | `status` | `status` | 身份/目录/消息数 |
|
|
53
53
|
|
|
54
|
+
## DSH 插件工具(agent 会话内直接调用)
|
|
55
|
+
|
|
56
|
+
上面是 CLI;**DSH 会话内还自带 7 个同名工具**,零配置、身份自动派生——不需要跑 CLI 就能收发。`mailbox_status` 的输出末尾会附完整命令一览,忘记有哪些功能时先调它:
|
|
57
|
+
|
|
58
|
+
| 工具 | 作用 |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `mailbox_send` | 发消息:`to`=identity/别名/完整 sessionId/`all`;`type`=notify/request/response/reply;可带 `topic`/`payload`/`replyTo` |
|
|
61
|
+
| `mailbox_recv` | 读新消息(自动 seen 去重;request 自动回执 delivered) |
|
|
62
|
+
| `mailbox_status` | 身份/写入目录/seen/各信箱未读 + 会话目录(**输出末尾附全部可用命令**) |
|
|
63
|
+
| `mailbox_sessions` | 会话目录:找"要对话的会话"(identity/别名/工作区/在线/未读) |
|
|
64
|
+
| `mailbox_alias` | 给本会话设唯一别名(如 `hub`/`rp`),便于他人定向发送;全库查重 |
|
|
65
|
+
| `mailbox_clean` | 按 TTL 清理自己发过的旧消息(`dryRun` 只统计不删) |
|
|
66
|
+
| `mailbox_reset` | ⚠️ **清空整个信箱根目录**:消息+seen+注册表 _sessions/(含别名),不可恢复;`confirm=true` 才执行;各会话下次调用自动重新登记(彻底清理/重建干净信箱用) |
|
|
67
|
+
|
|
68
|
+
另有宿主命令 `/mailbox <目标|别名|all> <消息>`(聊天框直发,输入 `/mailbox` 有补全)。CLI 侧 `reset` 用 `--confirm` 确认:`reset --confirm`。
|
|
69
|
+
|
|
54
70
|
## 配置
|
|
55
71
|
|
|
56
72
|
配置文件默认 `mailbox.config.json`(与工具同目录),可用 `-Config` / `--config` 或环境变量 `MAILBOX_CONFIG` 指定。
|
package/skill/SKILL.md
CHANGED
|
@@ -73,6 +73,22 @@ node 版命令一致:`node "$mb\mailbox.mjs" send --to agent-b --topic hello -
|
|
|
73
73
|
|
|
74
74
|
CLI 侧(无会话上下文)继续用显式 `--identity`;`send` 的 `--to` 同样支持别名(经注册表解析)。
|
|
75
75
|
|
|
76
|
+
### DSH 插件工具一览(会话内直接调用,无需 CLI)
|
|
77
|
+
|
|
78
|
+
DSH 会话内自带以下 7 个 mailbox 工具,**告诉你的用户"用 `mailbox_status` 就能看到全部命令"**。平时最常用的是前四个:
|
|
79
|
+
|
|
80
|
+
| 工具 | 用途 |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `mailbox_send` | 发消息:`to`=identity/别名/完整 sessionId/`all`;`type`=notify/request/response/reply;可带 `topic`/`payload`/`replyTo` |
|
|
83
|
+
| `mailbox_recv` | 读新消息(自动 seen 去重;request 自动回执 delivered) |
|
|
84
|
+
| `mailbox_status` | 本会话身份/写入目录/seen/各信箱未读 + 会话目录与在线状态(**输出末尾附全部可用命令**) |
|
|
85
|
+
| `mailbox_sessions` | 会话目录:找"要对话的会话"(identity/别名/工作区/在线/未读) |
|
|
86
|
+
| `mailbox_alias` | 给本会话设唯一别名(如 `hub`/`rp`),便于他人定向发送;全库查重 |
|
|
87
|
+
| `mailbox_clean` | 按 TTL 清理自己发过的旧消息(`dryRun` 只统计不删) |
|
|
88
|
+
| `mailbox_reset` | ⚠️ **清空整个信箱根目录**:所有消息 (msg_*.json) + seen + 注册表 _sessions/(含别名),不可恢复;`confirm=true` 才执行;各会话下次调用工具自动重新登记(用于彻底清理/重建干净信箱) |
|
|
89
|
+
|
|
90
|
+
另有宿主命令 `/mailbox <目标|别名|all> <消息>`(聊天框直发,`/mailbox` 空回车列会话目录、`/mailbox recv` 收信、输入有补全选择器)。CLI 侧(无会话上下文)用 `npx mailbox help` 查看:`send/recv/wait/poll/clean/status/sessions/reset`。
|
|
91
|
+
|
|
76
92
|
## 协议
|
|
77
93
|
|
|
78
94
|
- 消息文件:`msg_<id>.json`,内容 `{ id, from, to, type, topic, payload, ts, reply_to }`
|
package/skill/mailbox.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// 消息格式: { id, from, to, type, topic, payload, ts, reply_to }
|
|
16
16
|
// ============================================================================
|
|
17
17
|
|
|
18
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, rmSync } from "node:fs";
|
|
18
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, rmSync, renameSync } from "node:fs";
|
|
19
19
|
import { join, dirname, basename, resolve } from "node:path";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
|
|
@@ -98,25 +98,74 @@ function resolveDirs(cfg) {
|
|
|
98
98
|
return { out, in: inDirs };
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
const SEEN_EXT = ".seen";
|
|
102
|
+
|
|
101
103
|
function seenFileOf(cfg) {
|
|
102
104
|
if (cfg.seenFile) return cfg.seenFile;
|
|
103
105
|
return join(resolveDirs(cfg).out, ".seen.json");
|
|
104
106
|
}
|
|
105
107
|
|
|
106
|
-
function
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
function seenDirOf(cfg) {
|
|
109
|
+
if (cfg.seenFile) return "";
|
|
110
|
+
return join(resolveDirs(cfg).out, ".seen");
|
|
111
|
+
}
|
|
112
|
+
function seenMarkName(id) { return `${id}${SEEN_EXT}`; }
|
|
113
|
+
|
|
114
|
+
function migrateSeen(cfg, dir) {
|
|
115
|
+
const legacy = seenFileOf(cfg);
|
|
116
|
+
if (cfg.seenFile || !existsSync(legacy) || existsSync(dir)) return;
|
|
109
117
|
try {
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
118
|
+
const v = JSON.parse(readFileSync(legacy, "utf-8"));
|
|
119
|
+
const arr = Array.isArray(v) ? v : [v];
|
|
120
|
+
mkdirSync(dir, { recursive: true });
|
|
121
|
+
for (const id of arr) if (id) writeFileSync(join(dir, seenMarkName(id)), "", "utf-8");
|
|
122
|
+
rmSync(legacy, { force: true });
|
|
123
|
+
} catch { /* 迁移失败则保留旧文件 */ }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function loadSeen(cfg) {
|
|
127
|
+
const dir = seenDirOf(cfg);
|
|
128
|
+
if (!dir) {
|
|
129
|
+
const f = seenFileOf(cfg);
|
|
130
|
+
if (!existsSync(f)) return [];
|
|
131
|
+
try {
|
|
132
|
+
const v = JSON.parse(readFileSync(f, "utf-8"));
|
|
133
|
+
return Array.isArray(v) ? v : [v];
|
|
134
|
+
} catch { return []; }
|
|
135
|
+
}
|
|
136
|
+
migrateSeen(cfg, dir);
|
|
137
|
+
if (!existsSync(dir)) return [];
|
|
138
|
+
return readdirSync(dir).filter((x) => x.endsWith(SEEN_EXT)).map((x) => x.slice(0, -SEEN_EXT.length));
|
|
114
139
|
}
|
|
115
140
|
|
|
116
141
|
function saveSeen(cfg, seen) {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
142
|
+
const dir = seenDirOf(cfg);
|
|
143
|
+
if (!dir) {
|
|
144
|
+
const f = seenFileOf(cfg);
|
|
145
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
146
|
+
writeFileSync(f, JSON.stringify([...new Set(seen)]));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
mkdirSync(dir, { recursive: true });
|
|
150
|
+
for (const id of new Set(seen)) if (id) markSeen(cfg, id);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 目录化 seen: 每条已读消息一个 `.seen` 标记文件, 临时文件 + 原子 rename 写入 (并发安全)
|
|
154
|
+
function markSeen(cfg, id) {
|
|
155
|
+
if (!id) return;
|
|
156
|
+
const dir = seenDirOf(cfg);
|
|
157
|
+
if (!dir) {
|
|
158
|
+
const cur = loadSeen(cfg);
|
|
159
|
+
if (!cur.includes(id)) saveSeen(cfg, [...cur, id]);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
migrateSeen(cfg, dir);
|
|
163
|
+
mkdirSync(dir, { recursive: true });
|
|
164
|
+
const mark = join(dir, seenMarkName(id));
|
|
165
|
+
if (existsSync(mark)) return;
|
|
166
|
+
const tmp = join(dir, `.tmp-${id}`);
|
|
167
|
+
writeFileSync(tmp, "", "utf-8");
|
|
168
|
+
renameSync(tmp, mark);
|
|
120
169
|
}
|
|
121
170
|
|
|
122
171
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -165,6 +214,10 @@ function cmdSend(cfg, args) {
|
|
|
165
214
|
function cmdRecv(cfg, args) {
|
|
166
215
|
const msgs = recvNew(cfg, true);
|
|
167
216
|
if (msgs.length === 0) { console.log("(无新消息)"); return; }
|
|
217
|
+
let acked = 0;
|
|
218
|
+
if (!args["no-ack"]) {
|
|
219
|
+
for (const m of msgs) if (m.type === "request" && sendAck(cfg, m, "delivered")) acked++;
|
|
220
|
+
}
|
|
168
221
|
if (args.format === "json") {
|
|
169
222
|
for (const m of msgs) console.log(JSON.stringify(m));
|
|
170
223
|
} else {
|
|
@@ -175,9 +228,10 @@ function cmdRecv(cfg, args) {
|
|
|
175
228
|
if (p) console.log(` payload: ${p}`);
|
|
176
229
|
}
|
|
177
230
|
}
|
|
231
|
+
if (acked > 0) console.log(`[mailbox] 已自动回执 delivered 给 ${acked} 条 request ✓ (--no-ack 可关闭)`);
|
|
178
232
|
}
|
|
179
233
|
|
|
180
|
-
function recvNew(cfg,
|
|
234
|
+
function recvNew(cfg, mark) {
|
|
181
235
|
const seen = loadSeen(cfg);
|
|
182
236
|
const dirs = resolveDirs(cfg);
|
|
183
237
|
const fresh = [];
|
|
@@ -188,23 +242,61 @@ function recvNew(cfg, markSeen) {
|
|
|
188
242
|
const m = JSON.parse(readFileSync(join(dir, f), "utf-8"));
|
|
189
243
|
if ((m.to === cfg.identity || m.to === "all") && !seen.includes(m.id)) {
|
|
190
244
|
fresh.push(m);
|
|
191
|
-
if (
|
|
245
|
+
if (mark) markSeen(cfg, m.id);
|
|
192
246
|
}
|
|
193
247
|
} catch { /* 跳过损坏消息 */ }
|
|
194
248
|
}
|
|
195
249
|
}
|
|
196
|
-
if (markSeen) saveSeen(cfg, seen);
|
|
197
250
|
return fresh;
|
|
198
251
|
}
|
|
199
252
|
|
|
253
|
+
// ---- 回执协议 (ack): request 被消费自动回 delivered; 处理完回 done/error ----
|
|
254
|
+
function hasAck(cfg, requestId, status) {
|
|
255
|
+
const dirs = resolveDirs(cfg);
|
|
256
|
+
if (!existsSync(dirs.out)) return false;
|
|
257
|
+
for (const f of readdirSync(dirs.out)) {
|
|
258
|
+
if (!f.startsWith("msg_") || !f.endsWith(".json")) continue;
|
|
259
|
+
try {
|
|
260
|
+
const m = JSON.parse(readFileSync(join(dirs.out, f), "utf-8"));
|
|
261
|
+
if (m.reply_to === requestId && m.payload?.status === status) return true;
|
|
262
|
+
} catch { /* 跳过损坏消息 */ }
|
|
263
|
+
}
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function sendAck(cfg, request, status, extra = {}) {
|
|
268
|
+
if (!request || !request.id || !request.from) return false;
|
|
269
|
+
if (request.from === cfg.identity) return false;
|
|
270
|
+
if (!["delivered", "processing", "done", "error"].includes(status)) return false;
|
|
271
|
+
if (hasAck(cfg, request.id, status)) return false;
|
|
272
|
+
const dirs = resolveDirs(cfg);
|
|
273
|
+
mkdirSync(dirs.out, { recursive: true });
|
|
274
|
+
const id = `${ts()}${rand4()}-${rand4()}`;
|
|
275
|
+
const msg = {
|
|
276
|
+
id, from: cfg.identity, to: request.from,
|
|
277
|
+
type: "reply",
|
|
278
|
+
topic: "status",
|
|
279
|
+
payload: { status, requestId: request.id, ...extra },
|
|
280
|
+
ts: Date.now(),
|
|
281
|
+
reply_to: request.id,
|
|
282
|
+
};
|
|
283
|
+
writeFileSync(join(dirs.out, `msg_${id}.json`), JSON.stringify(msg) + "\n", "utf-8");
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
200
287
|
async function cmdWait(cfg, args) {
|
|
201
288
|
const timeoutSec = cfg.timeoutSec;
|
|
202
289
|
const started = Date.now();
|
|
203
290
|
for (;;) {
|
|
204
291
|
const msgs = recvNew(cfg, true);
|
|
205
292
|
if (msgs.length > 0) {
|
|
293
|
+
let acked = 0;
|
|
294
|
+
if (!args["no-ack"]) {
|
|
295
|
+
for (const m of msgs) if (m.type === "request" && sendAck(cfg, m, "delivered")) acked++;
|
|
296
|
+
}
|
|
206
297
|
console.log(`=== NEW MESSAGES: ${msgs.length} ===`);
|
|
207
298
|
for (const m of msgs) console.log(JSON.stringify(m));
|
|
299
|
+
if (acked > 0) console.log(`[mailbox] 已自动回执 delivered 给 ${acked} 条 request ✓ (--no-ack 可关闭)`);
|
|
208
300
|
console.log("=== WAKE-UP (exit 0) ===");
|
|
209
301
|
process.exit(0);
|
|
210
302
|
}
|