@yuanchilin/dsh-mailbox 0.0.1
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/LICENSE +21 -0
- package/README.md +89 -0
- package/bin/mailbox.mjs +225 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +110 -0
- package/lib/command.js +98 -0
- package/lib/core.js +441 -0
- package/lib/directory-parse.js +37 -0
- package/lib/index.js +319 -0
- package/lib/types/index.d.ts +123 -0
- package/lib/watcher.js +129 -0
- package/package.json +81 -0
- package/skill/README.md +117 -0
- package/skill/SKILL.md +96 -0
- package/skill/examples/handlers.patch.ps1 +154 -0
- package/skill/legacy-mcp.config.json +14 -0
- package/skill/mailbox.config.json +11 -0
- package/skill/mailbox.mjs +344 -0
- package/skill/mailbox.ps1 +218 -0
- package/skill/mailbox.psm1 +279 -0
- package/skill/self-test.ps1 +105 -0
package/lib/core.js
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @yuanchilin/dsh-mailbox — core
|
|
3
|
+
//
|
|
4
|
+
// 跨会话文件信箱核心逻辑(与 mailbox.psm1 / mailbox.mjs 同协议):
|
|
5
|
+
// - N 参与者对等模型: 每人一个信箱目录, 各写各的, 互读对方的
|
|
6
|
+
// - layout=root: <root>/<id>/ 每人一子目录 (participants 留空自动扫描)
|
|
7
|
+
// - layout=dirs: dirs: { "<id>": "<目录>" } 显式映射 (旧双目录兼容)
|
|
8
|
+
// - 消息: { id, from, to, type, topic, payload, ts, reply_to }, 文件 msg_<id>.json
|
|
9
|
+
// - 路由: to=<id> 定向 / to=all 广播 (写一份, 各人自取)
|
|
10
|
+
// - seen 去重: 每参与者独立 seen 文件 (默认 <outDir>/.seen.json)
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, rmSync } from "node:fs";
|
|
14
|
+
import { join, dirname, basename } from "node:path";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
|
|
17
|
+
/** 默认信箱根目录: DSH_HOME 已是 .dsh 主目录时直接接 mailbox (如 C:/Users/<user>/.dsh/mailbox); 无 DSH_HOME 时回退 ~/.dsh/mailbox。 */
|
|
18
|
+
export function defaultMailboxRoot() {
|
|
19
|
+
const home = process.env.DSH_HOME || join(homedir(), ".dsh");
|
|
20
|
+
return join(home, "mailbox");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const DEFAULTS = {
|
|
24
|
+
identity: "",
|
|
25
|
+
layout: "root", // "root" | "dirs"
|
|
26
|
+
root: defaultMailboxRoot(),
|
|
27
|
+
dirs: {},
|
|
28
|
+
participants: [],
|
|
29
|
+
intervalSec: 2,
|
|
30
|
+
timeoutSec: 0,
|
|
31
|
+
seenFile: "",
|
|
32
|
+
patchRoot: "",
|
|
33
|
+
presenceWindowSec: 300, // 在线判定窗口 (秒): lastSeen 在该窗口内视为在线
|
|
34
|
+
watcher: true, // 插件内建监听 (新消息 → 唤醒会话 agent; 重启自动复活)
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** 合并默认值(可选: 环境变量 > 显式覆盖)。插件侧直接传 Config 对象, CLI 侧先加载配置文件。 */
|
|
38
|
+
export function resolveConfig(partial = {}, env = {}) {
|
|
39
|
+
const cfg = { ...DEFAULTS, ...partial };
|
|
40
|
+
if (env.MAILBOX_ID) cfg.identity = env.MAILBOX_ID;
|
|
41
|
+
if (env.MAILBOX_ROOT) { cfg.root = env.MAILBOX_ROOT; cfg.layout = "root"; }
|
|
42
|
+
if (env.MAILBOX_INTERVAL) cfg.intervalSec = Number(env.MAILBOX_INTERVAL);
|
|
43
|
+
if (env.MAILBOX_TIMEOUT) cfg.timeoutSec = Number(env.MAILBOX_TIMEOUT);
|
|
44
|
+
if (!Array.isArray(cfg.participants)) cfg.participants = [];
|
|
45
|
+
return cfg;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveDirs(cfg) {
|
|
49
|
+
if (cfg.layout === "dirs") {
|
|
50
|
+
if (!cfg.dirs || !cfg.dirs[cfg.identity]) {
|
|
51
|
+
throw new Error(`layout=dirs 但配置缺少 identity '${cfg.identity}' 的目录映射`);
|
|
52
|
+
}
|
|
53
|
+
const out = cfg.dirs[cfg.identity];
|
|
54
|
+
const inDirs = [...new Set(Object.entries(cfg.dirs).filter(([k]) => k !== cfg.identity).map(([, v]) => v))];
|
|
55
|
+
return { out, in: inDirs };
|
|
56
|
+
}
|
|
57
|
+
if (!cfg.root) throw new Error("layout=root 需要配置 root");
|
|
58
|
+
if (!cfg.identity) throw new Error("layout=root 需要 identity (显式配置或按会话自动派生)");
|
|
59
|
+
const out = join(cfg.root, cfg.identity);
|
|
60
|
+
let participants = cfg.participants.filter(Boolean);
|
|
61
|
+
if (participants.length === 0 && existsSync(cfg.root)) {
|
|
62
|
+
participants = readdirSync(cfg.root, { withFileTypes: true })
|
|
63
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith("."))
|
|
64
|
+
.map((d) => d.name);
|
|
65
|
+
}
|
|
66
|
+
const inDirs = [...new Set(participants.filter((p) => p !== cfg.identity).map((p) => join(cfg.root, p)))];
|
|
67
|
+
return { out, in: inDirs };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function seenFileOf(cfg) {
|
|
71
|
+
if (cfg.seenFile) return cfg.seenFile;
|
|
72
|
+
return join(resolveDirs(cfg).out, ".seen.json");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function loadSeen(cfg) {
|
|
76
|
+
const f = seenFileOf(cfg);
|
|
77
|
+
if (!existsSync(f)) return [];
|
|
78
|
+
try {
|
|
79
|
+
// pwsh 旧版本可能把单元素 seen 写成裸字符串 "id", 归一化为数组
|
|
80
|
+
const v = JSON.parse(readFileSync(f, "utf-8"));
|
|
81
|
+
return Array.isArray(v) ? v : [v];
|
|
82
|
+
} catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function saveSeen(cfg, seen) {
|
|
88
|
+
const f = seenFileOf(cfg);
|
|
89
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
90
|
+
writeFileSync(f, JSON.stringify([...new Set(seen)]));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function newId() {
|
|
94
|
+
const d = new Date();
|
|
95
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
96
|
+
const ts = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
97
|
+
const rand4 = () => Math.random().toString(16).slice(2, 6).padEnd(4, "0");
|
|
98
|
+
return `${ts}-${rand4()}-${rand4()}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 发送: 写到自己的目录 (对方读你的目录)。to 支持 identity / 别名 / 完整 sessionId / all。返回消息 id。 */
|
|
102
|
+
export function sendMessage(cfg, { to, type = "notify", topic = "", payload = {}, replyTo = "" }) {
|
|
103
|
+
if (!to) throw new Error("send 需要 to (参与者 id 或 all)");
|
|
104
|
+
const target = resolveTarget(cfg, to);
|
|
105
|
+
const dirs = resolveDirs(cfg);
|
|
106
|
+
mkdirSync(dirs.out, { recursive: true });
|
|
107
|
+
const id = newId();
|
|
108
|
+
const msg = { id, from: cfg.identity, to: target, type, topic, payload, ts: Date.now(), reply_to: replyTo };
|
|
109
|
+
writeFileSync(join(dirs.out, `msg_${id}.json`), JSON.stringify(msg) + "\n", "utf-8");
|
|
110
|
+
return id;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 接收: 扫描所有对方目录, 取 to=自己 或 to=all 且未 seen 的消息。markSeen 默认 true。 */
|
|
114
|
+
export function recvNew(cfg, markSeen = true) {
|
|
115
|
+
const seen = loadSeen(cfg);
|
|
116
|
+
const dirs = resolveDirs(cfg);
|
|
117
|
+
const fresh = [];
|
|
118
|
+
for (const dir of dirs.in) {
|
|
119
|
+
if (!existsSync(dir)) continue;
|
|
120
|
+
for (const f of readdirSync(dir).filter((f) => f.startsWith("msg_") && f.endsWith(".json")).sort()) {
|
|
121
|
+
try {
|
|
122
|
+
const m = JSON.parse(readFileSync(join(dir, f), "utf-8"));
|
|
123
|
+
if ((m.to === cfg.identity || m.to === "all") && !seen.includes(m.id)) {
|
|
124
|
+
fresh.push(m);
|
|
125
|
+
if (markSeen) seen.push(m.id);
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// 跳过损坏消息
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (markSeen) saveSeen(cfg, seen);
|
|
133
|
+
return fresh;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 按 id 删除消息: inbox=true 删对方目录(已处理), 否则删自己的目录(已发送)。 */
|
|
137
|
+
export function removeMessage(cfg, id, inbox = false) {
|
|
138
|
+
const dirs = resolveDirs(cfg);
|
|
139
|
+
const targets = inbox ? dirs.in : [dirs.out];
|
|
140
|
+
for (const dir of targets) {
|
|
141
|
+
if (!existsSync(dir)) continue;
|
|
142
|
+
for (const f of readdirSync(dir)) {
|
|
143
|
+
if (!f.startsWith("msg_") || !f.endsWith(".json")) continue;
|
|
144
|
+
try {
|
|
145
|
+
const m = JSON.parse(readFileSync(join(dir, f), "utf-8"));
|
|
146
|
+
if (m.id === id) {
|
|
147
|
+
rmSync(join(dir, f), { force: true });
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// 跳过
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** TTL 清理: 删除自己 OutDir 中超过 ttlHours 的已发送消息 (收方应已读过)。返回删除数。 */
|
|
159
|
+
export function cleanTTL(cfg, { ttlHours = 24, dryRun = false } = {}) {
|
|
160
|
+
const dirs = resolveDirs(cfg);
|
|
161
|
+
if (!existsSync(dirs.out)) return 0;
|
|
162
|
+
const cutoff = Date.now() - ttlHours * 3600 * 1000;
|
|
163
|
+
let removed = 0;
|
|
164
|
+
for (const f of readdirSync(dirs.out)) {
|
|
165
|
+
if (!f.startsWith("msg_") || !f.endsWith(".json")) continue;
|
|
166
|
+
const p = join(dirs.out, f);
|
|
167
|
+
try {
|
|
168
|
+
if (statSync(p).mtimeMs < cutoff) {
|
|
169
|
+
if (!dryRun) rmSync(p, { force: true });
|
|
170
|
+
removed++;
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
// 跳过
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return removed;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 状态: 身份/布局/目录/消息数/未读数 + 会话目录(注册表)。 */
|
|
180
|
+
export function statusOf(cfg) {
|
|
181
|
+
const dirs = resolveDirs(cfg);
|
|
182
|
+
const seen = loadSeen(cfg);
|
|
183
|
+
const outCount = existsSync(dirs.out)
|
|
184
|
+
? readdirSync(dirs.out).filter((f) => f.startsWith("msg_")).length
|
|
185
|
+
: 0;
|
|
186
|
+
const inboxes = dirs.in.map((dir) => ({
|
|
187
|
+
dir,
|
|
188
|
+
name: basename(dir),
|
|
189
|
+
msgCount: existsSync(dir) ? readdirSync(dir).filter((f) => f.startsWith("msg_")).length : 0,
|
|
190
|
+
unread: unreadInDir(cfg, dir),
|
|
191
|
+
}));
|
|
192
|
+
const sessions = listSessions(cfg).map((s) => ({
|
|
193
|
+
...s,
|
|
194
|
+
online: isOnline(s, cfg.presenceWindowSec),
|
|
195
|
+
unread: unreadFrom(cfg, s.identity),
|
|
196
|
+
}));
|
|
197
|
+
return { identity: cfg.identity, layout: cfg.layout, outDir: dirs.out, outCount, seen: seen.length, inboxes, sessions };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 校验配置是否可用于收发 (identity/目录已解析)。 */
|
|
201
|
+
export function assertUsable(cfg) {
|
|
202
|
+
if (!cfg.identity) throw new Error("mailbox 未配置 identity (在 cordis.patch.yml 的 mailbox 配置中设置)");
|
|
203
|
+
resolveDirs(cfg); // 抛错即不可用
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ============================================================================
|
|
207
|
+
// 会话身份 + 注册表 (发现 / 在线 / 别名 / 目标解析)
|
|
208
|
+
//
|
|
209
|
+
// 背景: 一个工作区下可能开多个会话, 不能按工作区区分身份。
|
|
210
|
+
// 方案: 身份默认按 <工作区名>-<会话短id> 自动派生 (每会话唯一),
|
|
211
|
+
// 显式 config.identity 仍是最高优先级 (固定身份 / 旧配置兼容)。
|
|
212
|
+
// 注册表: <root>/_sessions/<sessionId>.json, 每次工具调用写心跳 (lastSeen),
|
|
213
|
+
// `mailbox_sessions` / `status` 据此展示会话目录与在线状态。
|
|
214
|
+
// ============================================================================
|
|
215
|
+
|
|
216
|
+
const REGISTRY_DIR = "_sessions"; // 位于 root 下, 参与者扫描会跳过 "_" 前缀目录
|
|
217
|
+
|
|
218
|
+
/** 从 DSH Session 提取会话上下文 (sessionId / workspace cwd / 折叠标题)。无会话返回 undefined。 */
|
|
219
|
+
export function sessionCtx(session) {
|
|
220
|
+
if (!session) return undefined;
|
|
221
|
+
const sessionId = typeof session.id === "string" ? session.id : "";
|
|
222
|
+
const workspace = typeof session.header?.cwd === "string" ? session.header.cwd : "";
|
|
223
|
+
let title = "";
|
|
224
|
+
const events = Array.isArray(session.events) ? session.events : [];
|
|
225
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
226
|
+
const e = events[i];
|
|
227
|
+
if (e && e.type === "session/title" && typeof e.data?.title === "string") {
|
|
228
|
+
title = e.data.title;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (!sessionId && !workspace && !title) return undefined;
|
|
233
|
+
return { sessionId, workspace, title };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function slugify(s) {
|
|
237
|
+
return String(s || "").replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "ws";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 按会话派生身份: <工作区名>-<会话短id> (同一工作区多会话互不冲突, 稳定唯一)。无真实会话返回空。 */
|
|
241
|
+
export function deriveIdentity(ctx) {
|
|
242
|
+
if (!ctx || !ctx.sessionId) return "";
|
|
243
|
+
const ws = slugify(ctx.workspace ? basename(ctx.workspace) : "workspace");
|
|
244
|
+
const short = String(ctx.sessionId).replace(/^session-/, "").slice(0, 8) || "anon";
|
|
245
|
+
return `${ws}-${short}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** 身份优先级: 显式 config.identity > 会话派生 > "" (无法确定)。 */
|
|
249
|
+
export function effectiveIdentity(cfg, session) {
|
|
250
|
+
if (cfg.identity) return cfg.identity;
|
|
251
|
+
return deriveIdentity(sessionCtx(session));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** 本次调用的有效配置: 解析身份, 无身份时抛出带指引的错误。 */
|
|
255
|
+
export function effectiveConfig(cfg, session) {
|
|
256
|
+
const identity = effectiveIdentity(cfg, session);
|
|
257
|
+
if (!identity) {
|
|
258
|
+
const hint = cfg.layout === "dirs"
|
|
259
|
+
? "layout=dirs 需要在配置中显式设置 identity"
|
|
260
|
+
: "identity 留空会自动按会话生成; 当前调用无会话上下文 (CLI 请显式传 --identity)";
|
|
261
|
+
throw new Error(`mailbox 无法确定身份: ${hint}`);
|
|
262
|
+
}
|
|
263
|
+
return { ...cfg, identity };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 注册表目录 (仅 layout=root): <root>/_sessions。 */
|
|
267
|
+
export function registryDir(cfg) {
|
|
268
|
+
if (cfg.layout !== "root" || !cfg.root) return "";
|
|
269
|
+
return join(cfg.root, REGISTRY_DIR);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function registryFileOf(cfg, key) {
|
|
273
|
+
const dir = registryDir(cfg);
|
|
274
|
+
return dir ? join(dir, `${key}.json`) : "";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function readRegistry(cfg, key) {
|
|
278
|
+
const f = registryFileOf(cfg, key);
|
|
279
|
+
if (!f || !existsSync(f)) return undefined;
|
|
280
|
+
try {
|
|
281
|
+
return JSON.parse(readFileSync(f, "utf-8"));
|
|
282
|
+
} catch {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 心跳: 每次工具调用更新本会话注册信息 (含在线时间戳)。无会话上下文返回 undefined。 */
|
|
288
|
+
export function touchRegistry(cfg, session, { alias } = {}) {
|
|
289
|
+
const ctx = sessionCtx(session);
|
|
290
|
+
const dir = registryDir(cfg);
|
|
291
|
+
if (!ctx || !dir) return undefined;
|
|
292
|
+
const key = ctx.sessionId || "unknown";
|
|
293
|
+
const prev = readRegistry(cfg, key) || {};
|
|
294
|
+
const now = Date.now();
|
|
295
|
+
const rec = {
|
|
296
|
+
sessionId: ctx.sessionId,
|
|
297
|
+
identity: effectiveIdentity(cfg, session),
|
|
298
|
+
alias: alias !== undefined ? alias : (prev.alias || ""),
|
|
299
|
+
workspace: ctx.workspace,
|
|
300
|
+
title: ctx.title || prev.title || "",
|
|
301
|
+
firstSeen: prev.firstSeen || now,
|
|
302
|
+
lastSeen: now,
|
|
303
|
+
};
|
|
304
|
+
mkdirSync(dir, { recursive: true });
|
|
305
|
+
writeFileSync(registryFileOf(cfg, key), JSON.stringify(rec, null, 2) + "\n", "utf-8");
|
|
306
|
+
return rec;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** CLI 侧心跳 (无会话上下文): 以 cli-<identity> 为键登记, 让其他会话看到 CLI 参与者。 */
|
|
310
|
+
export function touchRegistryCli(cfg, { workspace = "" } = {}) {
|
|
311
|
+
const dir = registryDir(cfg);
|
|
312
|
+
if (!dir || !cfg.identity) return undefined;
|
|
313
|
+
const key = `cli-${cfg.identity}`;
|
|
314
|
+
const prev = readRegistry(cfg, key) || {};
|
|
315
|
+
const now = Date.now();
|
|
316
|
+
const rec = {
|
|
317
|
+
sessionId: "",
|
|
318
|
+
identity: cfg.identity,
|
|
319
|
+
alias: prev.alias || "",
|
|
320
|
+
workspace: workspace || prev.workspace || "",
|
|
321
|
+
title: prev.title || "CLI",
|
|
322
|
+
firstSeen: prev.firstSeen || now,
|
|
323
|
+
lastSeen: now,
|
|
324
|
+
};
|
|
325
|
+
mkdirSync(dir, { recursive: true });
|
|
326
|
+
writeFileSync(registryFileOf(cfg, key), JSON.stringify(rec, null, 2) + "\n", "utf-8");
|
|
327
|
+
return rec;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** 会话目录: 所有已注册会话, 按 lastSeen 倒序。 */
|
|
331
|
+
export function listSessions(cfg) {
|
|
332
|
+
const dir = registryDir(cfg);
|
|
333
|
+
if (!dir || !existsSync(dir)) return [];
|
|
334
|
+
const out = [];
|
|
335
|
+
for (const f of readdirSync(dir)) {
|
|
336
|
+
if (!f.endsWith(".json")) continue;
|
|
337
|
+
try {
|
|
338
|
+
out.push(JSON.parse(readFileSync(join(dir, f), "utf-8")));
|
|
339
|
+
} catch {
|
|
340
|
+
// 跳过损坏记录
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return out.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** 在线判定: lastSeen 在 presenceWindowSec 窗口内视为在线。 */
|
|
347
|
+
export function isOnline(rec, windowSec) {
|
|
348
|
+
return !!(rec && rec.lastSeen && Date.now() - rec.lastSeen < (windowSec || 300) * 1000);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** 设置/更换本会话别名 (全库唯一, 拒绝被其他会话占用)。 */
|
|
352
|
+
export function setAlias(cfg, session, alias) {
|
|
353
|
+
const dir = registryDir(cfg);
|
|
354
|
+
if (!dir) throw new Error("setAlias 仅支持 layout=root (需配置 root)");
|
|
355
|
+
alias = String(alias || "").trim();
|
|
356
|
+
if (!alias) throw new Error("alias 不能为空");
|
|
357
|
+
if (!/^[a-zA-Z0-9._-]{1,32}$/.test(alias)) throw new Error("alias 仅允许字母数字 . _ - (≤32 字符)");
|
|
358
|
+
const me = sessionCtx(session);
|
|
359
|
+
if (!me || !me.sessionId) throw new Error("无法设置别名: 缺少会话上下文");
|
|
360
|
+
mkdirSync(dir, { recursive: true });
|
|
361
|
+
for (const f of readdirSync(dir)) {
|
|
362
|
+
if (!f.endsWith(".json")) continue;
|
|
363
|
+
let r;
|
|
364
|
+
try {
|
|
365
|
+
r = JSON.parse(readFileSync(join(dir, f), "utf-8"));
|
|
366
|
+
} catch {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (r.alias === alias && r.sessionId !== me.sessionId) {
|
|
370
|
+
throw new Error(`alias "${alias}" 已被 ${r.identity} 占用 (${r.workspace || "?"}${r.title ? ` / ${r.title}` : ""})`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return touchRegistry(cfg, session, { alias });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** 目标解析: all → all; 别名 / 完整 sessionId / 工作区路径或目录名 → 对应 identity; 否则原样 (视为 identity)。 */
|
|
377
|
+
export function resolveTarget(cfg, to) {
|
|
378
|
+
if (to === "all") return "all";
|
|
379
|
+
const sessions = listSessions(cfg);
|
|
380
|
+
const byAlias = sessions.find((s) => s.alias && s.alias === to);
|
|
381
|
+
if (byAlias) return byAlias.identity || to;
|
|
382
|
+
const byId = sessions.find((s) => s.sessionId === to);
|
|
383
|
+
if (byId) return byId.identity || to;
|
|
384
|
+
// 按工作区匹配: 完整路径 (正反斜杠归一) 或目录名 (如 D:/.../dsh-mailbox 或 dsh-mailbox)
|
|
385
|
+
const norm = (p) => String(p || "").replace(/\\/g, "/").replace(/\/+$/, "");
|
|
386
|
+
const targetNorm = norm(to);
|
|
387
|
+
const byWorkspace = sessions.find((s) => {
|
|
388
|
+
const ws = norm(s.workspace);
|
|
389
|
+
return ws === targetNorm || ws.split("/").pop() === targetNorm;
|
|
390
|
+
});
|
|
391
|
+
if (byWorkspace) return byWorkspace.identity || to;
|
|
392
|
+
return to;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** 目标是否已登记: all 或注册表中的 identity。 */
|
|
396
|
+
export function isKnownTarget(cfg, target) {
|
|
397
|
+
if (target === "all") return true;
|
|
398
|
+
return listSessions(cfg).some((s) => s.identity === target);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** 会话目录文本 (地址簿): 每行 ●在线/○离线 identity (alias) workspace «title»。 */
|
|
402
|
+
export function sessionDirectoryText(cfg) {
|
|
403
|
+
const sessions = listSessions(cfg);
|
|
404
|
+
if (sessions.length === 0) return "(暂无注册会话: 各会话调用一次 mailbox 工具即自动登记)";
|
|
405
|
+
return sessions
|
|
406
|
+
.map((s) => {
|
|
407
|
+
const on = isOnline(s, cfg.presenceWindowSec) ? "●在线" : "○离线";
|
|
408
|
+
const alias = s.alias ? ` (${s.alias})` : "";
|
|
409
|
+
const title = s.title ? ` «${s.title}»` : "";
|
|
410
|
+
return ` ${on} ${s.identity}${alias} ${s.workspace || "?"}${title}`;
|
|
411
|
+
})
|
|
412
|
+
.join("\n");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** 未知目标的提示文本 (失败即地址簿: 让发送者从错误里找到收件人)。 */
|
|
416
|
+
export function unknownTargetHint(cfg, target) {
|
|
417
|
+
return `目标 "${target}" 未登记。当前会话目录:\n${sessionDirectoryText(cfg)}`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** 统计某目录中发给我、尚未读的消息数。 */
|
|
421
|
+
export function unreadInDir(cfg, dir) {
|
|
422
|
+
if (!existsSync(dir)) return 0;
|
|
423
|
+
const seen = loadSeen(cfg);
|
|
424
|
+
let n = 0;
|
|
425
|
+
for (const f of readdirSync(dir)) {
|
|
426
|
+
if (!f.startsWith("msg_") || !f.endsWith(".json")) continue;
|
|
427
|
+
try {
|
|
428
|
+
const m = JSON.parse(readFileSync(join(dir, f), "utf-8"));
|
|
429
|
+
if ((m.to === cfg.identity || m.to === "all") && !seen.includes(m.id)) n++;
|
|
430
|
+
} catch {
|
|
431
|
+
// 跳过损坏消息
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return n;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** 统计某 identity 发给我、尚未读的消息数。 */
|
|
438
|
+
export function unreadFrom(cfg, identity) {
|
|
439
|
+
if (!cfg.root || !identity) return 0;
|
|
440
|
+
return unreadInDir(cfg, join(cfg.root, identity));
|
|
441
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @yuanchilin/dsh-mailbox — 会话目录文本解析 (客户端补全用)
|
|
3
|
+
//
|
|
4
|
+
// /mailbox 命令的裸执行输出 (renderDirectory) 是稳定格式:
|
|
5
|
+
// Usage: ...
|
|
6
|
+
//
|
|
7
|
+
// 会话目录:
|
|
8
|
+
// ●在线 dsh-mailbox-17cbcfa0 (hub) D:\Downloads\Agent\dsh-mailbox «标题»
|
|
9
|
+
// ○离线 RP-a4666ed6 D:\Downloads\Agent\RP «标题»
|
|
10
|
+
//
|
|
11
|
+
// 解析出 (identity, alias, workspace, title, online) 供 popupSelect 选项使用。
|
|
12
|
+
// 纯函数、零依赖, 可在 node 下单元测试, 客户端 bundle 直接内联。
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
const DIRECTORY_LINE = /^\s{2}([●○])(在线|离线)\s+(\S+)(?:\s+\(([^()]*)\))?\s+(.*)$/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 从 /mailbox 目录输出文本解析会话行。
|
|
19
|
+
* @returns { id, label, detail }[] (popupSelect SelectOption 形状)
|
|
20
|
+
*/
|
|
21
|
+
export function parseDirectory(text) {
|
|
22
|
+
const rows = [];
|
|
23
|
+
if (typeof text !== "string") return rows;
|
|
24
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
25
|
+
const m = DIRECTORY_LINE.exec(raw);
|
|
26
|
+
if (!m) continue;
|
|
27
|
+
const identity = m[3];
|
|
28
|
+
const alias = m[4] ?? "";
|
|
29
|
+
const rest = (m[5] ?? "").trim();
|
|
30
|
+
rows.push({
|
|
31
|
+
id: identity,
|
|
32
|
+
label: alias ? `${identity} (${alias})` : identity,
|
|
33
|
+
detail: `${m[1] === "●" ? "●在线" : "○离线"} ${rest || "?"}`,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return rows;
|
|
37
|
+
}
|