herdr-plugin-amq 0.1.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/LICENSE +21 -0
- package/README.md +263 -0
- package/bin/herdr-amq.mjs +74 -0
- package/herdr-plugin.toml +57 -0
- package/package.json +50 -0
- package/skills/herdr-amq/SKILL.md +69 -0
- package/src/actions.mjs +573 -0
- package/src/blobs.mjs +348 -0
- package/src/board.mjs +760 -0
- package/src/bridge.mjs +373 -0
- package/src/briefs.mjs +201 -0
- package/src/config.mjs +146 -0
- package/src/herdr.mjs +215 -0
- package/src/index.mjs +4 -0
- package/src/markdown.mjs +167 -0
- package/src/panes.mjs +68 -0
- package/src/protocol.mjs +347 -0
- package/src/server.mjs +773 -0
- package/src/store.mjs +1063 -0
- package/src/web/app.js +3066 -0
- package/src/web/index.html +727 -0
- package/src/web/style.css +3842 -0
- package/src/worktrees.mjs +217 -0
package/src/store.mjs
ADDED
|
@@ -0,0 +1,1063 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import { execCmd, getHerdrBin, getAgentHandles, getRepoRootFromAmq } from "./config.mjs";
|
|
6
|
+
import { scanAgentBriefs, getAgentBrief, saveAgentBrief } from "./briefs.mjs";
|
|
7
|
+
import { ingestAttachment } from "./blobs.mjs";
|
|
8
|
+
import { sendMaildirMessage, replyMaildirMessage, drainMaildir } from "./protocol.mjs";
|
|
9
|
+
|
|
10
|
+
const PALETTE = [
|
|
11
|
+
"#1a73e8", "#ea4335", "#fbbc05", "#34a853", "#ff6d00",
|
|
12
|
+
"#9c27b0", "#009688", "#e91e63", "#3f51b5", "#00bcd4",
|
|
13
|
+
"#795548", "#607d8b", "#673ab7", "#2e7d32", "#c2185b"
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export function getAgentColor(handle = "") {
|
|
17
|
+
let hash = 0;
|
|
18
|
+
for (let i = 0; i < handle.length; i++) {
|
|
19
|
+
hash = (hash << 5) - hash + handle.charCodeAt(i);
|
|
20
|
+
hash |= 0;
|
|
21
|
+
}
|
|
22
|
+
return PALETTE[Math.abs(hash) % PALETTE.length];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function formatAgentTitle(handle = "") {
|
|
26
|
+
if (!handle) return "Agent";
|
|
27
|
+
if (handle.toLowerCase() === "user") return "User";
|
|
28
|
+
return handle
|
|
29
|
+
.split(/[-_]+/)
|
|
30
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
31
|
+
.join(" ");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const filePathCache = new Map();
|
|
35
|
+
let cacheTimestamp = 0;
|
|
36
|
+
|
|
37
|
+
function findInTree(dir, targetName) {
|
|
38
|
+
const skip = new Set([".git", ".godot", "node_modules", ".agent-mail", "dist", "build"]);
|
|
39
|
+
const queue = [dir];
|
|
40
|
+
while (queue.length > 0) {
|
|
41
|
+
const current = queue.shift();
|
|
42
|
+
let entries;
|
|
43
|
+
try {
|
|
44
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
45
|
+
} catch {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
for (const ent of entries) {
|
|
49
|
+
if (ent.isDirectory()) {
|
|
50
|
+
if (!skip.has(ent.name)) {
|
|
51
|
+
queue.push(path.join(current, ent.name));
|
|
52
|
+
}
|
|
53
|
+
} else if (ent.name === targetName) {
|
|
54
|
+
return path.join(current, ent.name);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatFileSize(bytes) {
|
|
62
|
+
if (!bytes || bytes <= 0) return "0 B";
|
|
63
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
64
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
65
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function isPathSafe(filePath, repoRoot, amqRoot) {
|
|
69
|
+
if (!filePath || typeof filePath !== "string") return false;
|
|
70
|
+
const normalized = path.resolve(filePath);
|
|
71
|
+
const lower = normalized.toLowerCase();
|
|
72
|
+
|
|
73
|
+
const forbidden = [
|
|
74
|
+
"/.ssh",
|
|
75
|
+
"/.env",
|
|
76
|
+
"/.git",
|
|
77
|
+
"/etc/",
|
|
78
|
+
"/proc/",
|
|
79
|
+
"/sys/",
|
|
80
|
+
"/root",
|
|
81
|
+
"id_rsa",
|
|
82
|
+
"id_ed25519",
|
|
83
|
+
"id_ecdsa",
|
|
84
|
+
"credentials",
|
|
85
|
+
".pem",
|
|
86
|
+
".key",
|
|
87
|
+
".bash_history",
|
|
88
|
+
];
|
|
89
|
+
for (const f of forbidden) {
|
|
90
|
+
if (lower.includes(f)) return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const base = path.basename(normalized);
|
|
94
|
+
if (base.startsWith(".") && base !== ".agent-mail") return false;
|
|
95
|
+
|
|
96
|
+
const allowedRoots = [
|
|
97
|
+
path.resolve(os.tmpdir()),
|
|
98
|
+
"/tmp",
|
|
99
|
+
"/private/tmp",
|
|
100
|
+
path.resolve(repoRoot || process.cwd()),
|
|
101
|
+
path.resolve(amqRoot || process.cwd()),
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
return allowedRoots.some((allowed) => {
|
|
105
|
+
return normalized === allowed || normalized.startsWith(allowed + path.sep);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve relative or bare file references to actual files on disk
|
|
111
|
+
*/
|
|
112
|
+
export function resolveAttachmentPath(ref, amqRoot) {
|
|
113
|
+
if (!ref || typeof ref !== "string") return null;
|
|
114
|
+
const clean = ref.trim().replace(/^["'<(\[]+|[>"')\],;:]+$/g, "");
|
|
115
|
+
if (!clean) return null;
|
|
116
|
+
|
|
117
|
+
const repoRoot = getRepoRootFromAmq(amqRoot);
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
if (now - cacheTimestamp > 30000) {
|
|
120
|
+
filePathCache.clear();
|
|
121
|
+
cacheTimestamp = now;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const cacheKey = `${repoRoot}::${clean}`;
|
|
125
|
+
if (filePathCache.has(cacheKey)) {
|
|
126
|
+
return filePathCache.get(cacheKey);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function testFile(p) {
|
|
130
|
+
try {
|
|
131
|
+
if (!isPathSafe(p, repoRoot, amqRoot)) return null;
|
|
132
|
+
if (fs.existsSync(p) && fs.statSync(p).isFile()) {
|
|
133
|
+
const resolved = path.resolve(p);
|
|
134
|
+
filePathCache.set(cacheKey, resolved);
|
|
135
|
+
return resolved;
|
|
136
|
+
}
|
|
137
|
+
} catch {}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 1. Direct path check (if absolute or starts with /)
|
|
142
|
+
if (clean.startsWith("/")) {
|
|
143
|
+
const res = testFile(clean);
|
|
144
|
+
if (res) return res;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 2. Relative to repoRoot
|
|
148
|
+
const fromRepo = testFile(path.join(repoRoot, clean));
|
|
149
|
+
if (fromRepo) return fromRepo;
|
|
150
|
+
|
|
151
|
+
// 3. In os.tmpdir() or os.tmpdir()/shooter
|
|
152
|
+
const base = path.basename(clean);
|
|
153
|
+
const fromTmpSub = testFile(path.join(os.tmpdir(), "shooter", base));
|
|
154
|
+
if (fromTmpSub) return fromTmpSub;
|
|
155
|
+
|
|
156
|
+
const fromTmp = testFile(path.join(os.tmpdir(), base));
|
|
157
|
+
if (fromTmp) return fromTmp;
|
|
158
|
+
|
|
159
|
+
// 4. In amqRoot/attachments
|
|
160
|
+
if (amqRoot) {
|
|
161
|
+
const fromAmqAtt = testFile(path.join(amqRoot, "attachments", base));
|
|
162
|
+
if (fromAmqAtt) return fromAmqAtt;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 5. Common subdirectories in repo
|
|
166
|
+
const subdirs = [
|
|
167
|
+
"src",
|
|
168
|
+
"test",
|
|
169
|
+
"docs",
|
|
170
|
+
"tools",
|
|
171
|
+
"scripts",
|
|
172
|
+
"resources",
|
|
173
|
+
"scenes",
|
|
174
|
+
"assets",
|
|
175
|
+
];
|
|
176
|
+
for (const sub of subdirs) {
|
|
177
|
+
const candidate = testFile(path.join(repoRoot, sub, base));
|
|
178
|
+
if (candidate) return candidate;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 6. Fast tree walk in repoRoot for the basename
|
|
182
|
+
const found = findInTree(repoRoot, base);
|
|
183
|
+
if (found) {
|
|
184
|
+
const resolved = path.resolve(found);
|
|
185
|
+
filePathCache.set(cacheKey, resolved);
|
|
186
|
+
return resolved;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
filePathCache.set(cacheKey, null);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Extract attachments and referenced images/logs from body or frontmatter,
|
|
195
|
+
* verifying presence on disk and providing existence flags.
|
|
196
|
+
*/
|
|
197
|
+
export function extractAttachments(body = "", metaAttachments = [], amqRoot = null) {
|
|
198
|
+
const attachments = [];
|
|
199
|
+
const seen = new Set();
|
|
200
|
+
const repoRoot = getRepoRootFromAmq(amqRoot);
|
|
201
|
+
|
|
202
|
+
function addCandidate(rawRef) {
|
|
203
|
+
if (!rawRef) return;
|
|
204
|
+
let clean = typeof rawRef === "string" ? rawRef.trim().replace(/^["'<(\[]+|[>"')\],;:]+$/g, "") : "";
|
|
205
|
+
if (typeof rawRef === "object") {
|
|
206
|
+
clean = rawRef.path || rawRef.sha256 || rawRef.name || "";
|
|
207
|
+
}
|
|
208
|
+
if (!clean || seen.has(clean) || clean.startsWith("http://") || clean.startsWith("https://")) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
seen.add(clean);
|
|
212
|
+
|
|
213
|
+
const base = path.basename(clean);
|
|
214
|
+
if (["config.json", "package.json", "pyproject.toml", "Cargo.toml", "flake.lock"].includes(base)) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 1. Try hybrid CAS ingestion / Git pinning (Option A + B)
|
|
219
|
+
if (amqRoot) {
|
|
220
|
+
try {
|
|
221
|
+
const ingested = ingestAttachment(rawRef, amqRoot, repoRoot);
|
|
222
|
+
if (ingested && ingested.exists) {
|
|
223
|
+
attachments.push({
|
|
224
|
+
...ingested,
|
|
225
|
+
originalRef: typeof rawRef === "string" ? clean : (rawRef.name || clean),
|
|
226
|
+
sizeDisplay: formatFileSize(ingested.sizeBytes || 0),
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
} catch {}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 2. Fallback to disk resolution
|
|
234
|
+
const ext = path.extname(clean).toLowerCase();
|
|
235
|
+
const isImage = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"].includes(ext);
|
|
236
|
+
const isLog = [".log", ".txt", ".csv", ".json", ".out", ".diff", ".patch"].includes(ext);
|
|
237
|
+
|
|
238
|
+
const resolved = resolveAttachmentPath(clean, amqRoot);
|
|
239
|
+
const exists = Boolean(resolved);
|
|
240
|
+
let sizeBytes = 0;
|
|
241
|
+
if (exists) {
|
|
242
|
+
try {
|
|
243
|
+
sizeBytes = fs.statSync(resolved).size;
|
|
244
|
+
} catch {}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
attachments.push({
|
|
248
|
+
path: resolved || clean,
|
|
249
|
+
originalRef: clean,
|
|
250
|
+
name: base,
|
|
251
|
+
ext,
|
|
252
|
+
isImage,
|
|
253
|
+
isLog,
|
|
254
|
+
exists,
|
|
255
|
+
sizeBytes,
|
|
256
|
+
sizeDisplay: exists ? formatFileSize(sizeBytes) : "Missing on disk",
|
|
257
|
+
url: exists ? `/api/file?path=${encodeURIComponent(resolved || clean)}` : null,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (Array.isArray(metaAttachments)) {
|
|
262
|
+
for (const a of metaAttachments) {
|
|
263
|
+
addCandidate(a);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Auto-scan body for referenced files (/tmp/..., /nix/..., or relative paths)
|
|
268
|
+
const regex = /(?:(?:\/(?:tmp|home|nix)[\w./-]+)|(?:[\w./-]+))\.(?:png|jpg|jpeg|gif|webp|svg|bmp|log|txt|csv|json|diff|patch|out|gd|tres|tscn|sh|md)\b/gi;
|
|
269
|
+
const matches = body.match(regex) || [];
|
|
270
|
+
|
|
271
|
+
for (const m of matches) {
|
|
272
|
+
addCandidate(m);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return attachments;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Levenshtein distance for fuzzy matching
|
|
280
|
+
*/
|
|
281
|
+
function levenshtein(a, b) {
|
|
282
|
+
if (a.length === 0) return b.length;
|
|
283
|
+
if (b.length === 0) return a.length;
|
|
284
|
+
const matrix = [];
|
|
285
|
+
for (let i = 0; i <= b.length; i++) matrix[i] = [i];
|
|
286
|
+
for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
|
|
287
|
+
for (let i = 1; i <= b.length; i++) {
|
|
288
|
+
for (let j = 1; j <= a.length; j++) {
|
|
289
|
+
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
|
290
|
+
matrix[i][j] = matrix[i - 1][j - 1];
|
|
291
|
+
} else {
|
|
292
|
+
matrix[i][j] = Math.min(
|
|
293
|
+
matrix[i - 1][j - 1] + 1,
|
|
294
|
+
Math.min(matrix[i][j - 1] + 1, matrix[i - 1][j] + 1)
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return matrix[b.length][a.length];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Fuzzy term matcher across target text
|
|
304
|
+
*/
|
|
305
|
+
function matchFuzzyTerm(targetText, term) {
|
|
306
|
+
if (!targetText || !term) return false;
|
|
307
|
+
const text = targetText.toLowerCase();
|
|
308
|
+
const t = term.toLowerCase();
|
|
309
|
+
|
|
310
|
+
// Direct substring match
|
|
311
|
+
if (text.includes(t)) return true;
|
|
312
|
+
|
|
313
|
+
// Word-level fuzzy match
|
|
314
|
+
if (t.length >= 4) {
|
|
315
|
+
const words = text.split(/[\s,./_:-]+/);
|
|
316
|
+
for (const w of words) {
|
|
317
|
+
if (Math.abs(w.length - t.length) <= 2) {
|
|
318
|
+
if (levenshtein(w, t) <= (t.length <= 5 ? 1 : 2)) {
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Parse advanced search queries:
|
|
329
|
+
* from:coordinator, to:me, recipient:user, has:image, has:attachment, is:unread, is:starred, free text
|
|
330
|
+
*/
|
|
331
|
+
export function parseQuery(queryStr = "", currentAccount = "user", persona = "") {
|
|
332
|
+
let meHandle = persona || currentAccount;
|
|
333
|
+
if (!meHandle || meHandle === "all") {
|
|
334
|
+
meHandle = "user";
|
|
335
|
+
}
|
|
336
|
+
const me = meHandle.toLowerCase();
|
|
337
|
+
const filters = {
|
|
338
|
+
from: [],
|
|
339
|
+
to: [],
|
|
340
|
+
hasImage: false,
|
|
341
|
+
hasAttachment: false,
|
|
342
|
+
isUnread: false,
|
|
343
|
+
isStarred: false,
|
|
344
|
+
terms: [],
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const tokens = queryStr.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
|
348
|
+
for (const token of tokens) {
|
|
349
|
+
const lower = token.toLowerCase();
|
|
350
|
+
if (lower === "has:image" || lower === "has:images") {
|
|
351
|
+
filters.hasImage = true;
|
|
352
|
+
} else if (lower === "has:attachment" || lower === "has:attachments") {
|
|
353
|
+
filters.hasAttachment = true;
|
|
354
|
+
} else if (lower === "is:unread" || lower === "is:new") {
|
|
355
|
+
filters.isUnread = true;
|
|
356
|
+
} else if (lower === "is:starred") {
|
|
357
|
+
filters.isStarred = true;
|
|
358
|
+
} else if (lower.startsWith("from:")) {
|
|
359
|
+
let val = token.slice(5).replace(/^"|"$/g, "");
|
|
360
|
+
if (val.toLowerCase() === "me") val = me;
|
|
361
|
+
filters.from.push(val.toLowerCase());
|
|
362
|
+
} else if (lower.startsWith("to:") || lower.startsWith("recipient:")) {
|
|
363
|
+
const prefixLen = lower.startsWith("to:") ? 3 : 10;
|
|
364
|
+
let val = token.slice(prefixLen).replace(/^"|"$/g, "");
|
|
365
|
+
if (val.toLowerCase() === "me") val = me;
|
|
366
|
+
filters.to.push(val.toLowerCase());
|
|
367
|
+
} else {
|
|
368
|
+
const clean = token.replace(/^"|"$/g, "").trim();
|
|
369
|
+
if (clean) filters.terms.push(clean);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return filters;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Match a single message against parsed query filters
|
|
378
|
+
*/
|
|
379
|
+
export function matchesFilter(msg, parsedQuery) {
|
|
380
|
+
if (parsedQuery.hasImage && !msg.hasImage) return false;
|
|
381
|
+
if (parsedQuery.hasAttachment && !msg.hasAttachment) return false;
|
|
382
|
+
if (parsedQuery.isUnread && !msg.isNew) return false;
|
|
383
|
+
|
|
384
|
+
if (parsedQuery.from.length > 0) {
|
|
385
|
+
const fromLower = (msg.from || "").toLowerCase();
|
|
386
|
+
const matchFrom = parsedQuery.from.some((f) => fromLower.includes(f));
|
|
387
|
+
if (!matchFrom) return false;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (parsedQuery.to.length > 0) {
|
|
391
|
+
const toList = Array.isArray(msg.to)
|
|
392
|
+
? msg.to.map((t) => t.toLowerCase())
|
|
393
|
+
: [(msg.to || "").toLowerCase()];
|
|
394
|
+
const matchTo = parsedQuery.to.some((t) => toList.some((recipient) => recipient.includes(t)));
|
|
395
|
+
if (!matchTo) return false;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (parsedQuery.terms.length > 0) {
|
|
399
|
+
const combined = `${msg.subject || ""} ${msg.snippet || ""} ${msg.from || ""} ${msg.body || ""} ${msg.thread || ""}`;
|
|
400
|
+
for (const term of parsedQuery.terms) {
|
|
401
|
+
if (!matchFuzzyTerm(combined, term)) return false;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Parse a markdown AMQ message file containing ---json frontmatter
|
|
410
|
+
*/
|
|
411
|
+
export function parseMessageFile(filePath, amqRoot = null) {
|
|
412
|
+
try {
|
|
413
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
414
|
+
const match = raw.match(/^---(?:json)?\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
|
|
415
|
+
if (!match) return null;
|
|
416
|
+
|
|
417
|
+
const meta = JSON.parse(match[1]);
|
|
418
|
+
const body = match[2] || "";
|
|
419
|
+
|
|
420
|
+
const isNew = filePath.includes("/inbox/new/");
|
|
421
|
+
const isCur = filePath.includes("/inbox/cur/");
|
|
422
|
+
const isOutbox = filePath.includes("/outbox/");
|
|
423
|
+
|
|
424
|
+
let folder = "inbox";
|
|
425
|
+
if (isOutbox) folder = "sent";
|
|
426
|
+
|
|
427
|
+
const root = amqRoot || (filePath.includes("/agents/") ? filePath.split(path.sep + "agents" + path.sep)[0] : null);
|
|
428
|
+
const attachments = extractAttachments(body, meta.attachments, root);
|
|
429
|
+
const hasImage = attachments.some((a) => a.isImage);
|
|
430
|
+
const hasAttachment = attachments.length > 0;
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
...meta,
|
|
434
|
+
body,
|
|
435
|
+
filePath,
|
|
436
|
+
isNew,
|
|
437
|
+
isCur,
|
|
438
|
+
folder,
|
|
439
|
+
snippet: body.replace(/\n+/g, " ").slice(0, 140),
|
|
440
|
+
attachments,
|
|
441
|
+
hasImage,
|
|
442
|
+
hasAttachment,
|
|
443
|
+
};
|
|
444
|
+
} catch {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* In-memory mtime cache for parsed messages (reduces 10s cold scans to sub-10ms)
|
|
451
|
+
*/
|
|
452
|
+
const messageParseCache = new Map(); // fullPath -> { mtimeMs, size, parsed }
|
|
453
|
+
|
|
454
|
+
export function invalidateMessageCache() {
|
|
455
|
+
messageParseCache.clear();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function getCachedMessage(fullPath, amqRoot) {
|
|
459
|
+
try {
|
|
460
|
+
const stat = fs.statSync(fullPath);
|
|
461
|
+
const cached = messageParseCache.get(fullPath);
|
|
462
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
463
|
+
return cached.parsed;
|
|
464
|
+
}
|
|
465
|
+
const parsed = parseMessageFile(fullPath, amqRoot);
|
|
466
|
+
if (parsed) {
|
|
467
|
+
messageParseCache.set(fullPath, {
|
|
468
|
+
mtimeMs: stat.mtimeMs,
|
|
469
|
+
size: stat.size,
|
|
470
|
+
parsed,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
return parsed;
|
|
474
|
+
} catch {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Recursively collect message files (.md, .json) from a directory and its subdirectories
|
|
481
|
+
*/
|
|
482
|
+
function collectMessageFiles(dir) {
|
|
483
|
+
if (!fs.existsSync(dir)) return [];
|
|
484
|
+
const results = [];
|
|
485
|
+
try {
|
|
486
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
487
|
+
for (const ent of entries) {
|
|
488
|
+
const full = path.join(dir, ent.name);
|
|
489
|
+
if (ent.isDirectory()) {
|
|
490
|
+
if (ent.name === "tmp") continue;
|
|
491
|
+
try {
|
|
492
|
+
const subEntries = fs.readdirSync(full, { withFileTypes: true });
|
|
493
|
+
for (const sub of subEntries) {
|
|
494
|
+
if (!sub.isDirectory() && (sub.name.endsWith(".md") || sub.name.endsWith(".json"))) {
|
|
495
|
+
results.push(path.join(full, sub.name));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
} catch {}
|
|
499
|
+
} else if (ent.name.endsWith(".md") || ent.name.endsWith(".json")) {
|
|
500
|
+
results.push(full);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
} catch {}
|
|
504
|
+
return results;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Scan all messages across mailboxes in an AMQ root with caching & pagination
|
|
509
|
+
*/
|
|
510
|
+
export function loadAllMessages(
|
|
511
|
+
amqRoot,
|
|
512
|
+
{
|
|
513
|
+
account = "all",
|
|
514
|
+
folder = "inbox",
|
|
515
|
+
query = "",
|
|
516
|
+
persona = "",
|
|
517
|
+
page = 1,
|
|
518
|
+
pageSize = 50,
|
|
519
|
+
paginate = false,
|
|
520
|
+
} = {}
|
|
521
|
+
) {
|
|
522
|
+
if (!amqRoot || !fs.existsSync(amqRoot)) return paginate ? { items: [], total: 0, page: 1, pageSize: 50, totalPages: 1 } : [];
|
|
523
|
+
|
|
524
|
+
const agentsDir = path.join(amqRoot, "agents");
|
|
525
|
+
if (!fs.existsSync(agentsDir)) return paginate ? { items: [], total: 0, page: 1, pageSize: 50, totalPages: 1 } : [];
|
|
526
|
+
|
|
527
|
+
const handles = fs.readdirSync(agentsDir).filter((h) => {
|
|
528
|
+
return !h.startsWith(".") && fs.statSync(path.join(agentsDir, h)).isDirectory();
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const targetHandles = account === "all" ? handles : [account];
|
|
532
|
+
const messageMap = new Map(); // id -> message
|
|
533
|
+
|
|
534
|
+
// Freeform search queries search across all folders
|
|
535
|
+
const isSearch = Boolean(query && query.trim());
|
|
536
|
+
const effectiveFolder = isSearch ? "all" : folder;
|
|
537
|
+
|
|
538
|
+
for (const h of targetHandles) {
|
|
539
|
+
const handleDir = path.join(agentsDir, h);
|
|
540
|
+
const targetDirs = [];
|
|
541
|
+
|
|
542
|
+
if (effectiveFolder === "inbox" || effectiveFolder === "all" || effectiveFolder === "starred" || effectiveFolder === "threads") {
|
|
543
|
+
targetDirs.push(path.join(handleDir, "inbox"));
|
|
544
|
+
}
|
|
545
|
+
if (effectiveFolder === "sent" || effectiveFolder === "all") {
|
|
546
|
+
targetDirs.push(path.join(handleDir, "outbox"));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
for (const d of targetDirs) {
|
|
550
|
+
const filePaths = collectMessageFiles(d);
|
|
551
|
+
for (const fullPath of filePaths) {
|
|
552
|
+
const msg = getCachedMessage(fullPath, amqRoot);
|
|
553
|
+
if (!msg) continue;
|
|
554
|
+
|
|
555
|
+
if (fullPath.includes("/outbox/")) {
|
|
556
|
+
msg.folder = "sent";
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const existing = messageMap.get(msg.id);
|
|
560
|
+
if (!existing) {
|
|
561
|
+
messageMap.set(msg.id, {
|
|
562
|
+
...msg,
|
|
563
|
+
targetAccount: h,
|
|
564
|
+
});
|
|
565
|
+
} else {
|
|
566
|
+
if (msg.isNew || (effectiveFolder === "sent" && msg.folder === "sent")) {
|
|
567
|
+
messageMap.set(msg.id, {
|
|
568
|
+
...msg,
|
|
569
|
+
targetAccount: h,
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
let list = Array.from(messageMap.values());
|
|
578
|
+
|
|
579
|
+
// Filter by query if provided
|
|
580
|
+
if (query && query.trim()) {
|
|
581
|
+
const parsedQuery = parseQuery(query, account, persona);
|
|
582
|
+
list = list.filter((m) => matchesFilter(m, parsedQuery));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Sort descending by created date
|
|
586
|
+
list.sort((a, b) => {
|
|
587
|
+
const timeA = a.created ? new Date(a.created).getTime() : 0;
|
|
588
|
+
const timeB = b.created ? new Date(b.created).getTime() : 0;
|
|
589
|
+
return timeB - timeA;
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
const total = list.length;
|
|
593
|
+
if (paginate) {
|
|
594
|
+
const p = Math.max(1, parseInt(page, 10) || 1);
|
|
595
|
+
const limit = Math.max(1, parseInt(pageSize, 10) || 50);
|
|
596
|
+
const start = (p - 1) * limit;
|
|
597
|
+
const items = list.slice(start, start + limit);
|
|
598
|
+
return {
|
|
599
|
+
items,
|
|
600
|
+
total,
|
|
601
|
+
page: p,
|
|
602
|
+
pageSize: limit,
|
|
603
|
+
totalPages: Math.ceil(total / limit) || 1,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return list;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Group messages into conversation threads (Gmail Conversation View) with caching & pagination
|
|
612
|
+
*/
|
|
613
|
+
export function loadThreads(
|
|
614
|
+
amqRoot,
|
|
615
|
+
{
|
|
616
|
+
account = "all",
|
|
617
|
+
folder = "inbox",
|
|
618
|
+
query = "",
|
|
619
|
+
persona = "",
|
|
620
|
+
page = 1,
|
|
621
|
+
pageSize = 50,
|
|
622
|
+
paginate = false,
|
|
623
|
+
} = {}
|
|
624
|
+
) {
|
|
625
|
+
const msgs = loadAllMessages(amqRoot, { account, folder: "all", query: "", persona, paginate: false });
|
|
626
|
+
const threadMap = new Map();
|
|
627
|
+
|
|
628
|
+
for (const m of msgs) {
|
|
629
|
+
const threadId = m.thread || m.id;
|
|
630
|
+
if (!threadMap.has(threadId)) {
|
|
631
|
+
threadMap.set(threadId, {
|
|
632
|
+
threadId,
|
|
633
|
+
subject: m.subject || "(no subject)",
|
|
634
|
+
participants: new Set(),
|
|
635
|
+
messages: [],
|
|
636
|
+
hasUnread: false,
|
|
637
|
+
latestCreated: m.created,
|
|
638
|
+
latestSnippet: m.snippet,
|
|
639
|
+
latestFrom: m.from,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const t = threadMap.get(threadId);
|
|
644
|
+
if (m.from) t.participants.add(m.from);
|
|
645
|
+
if (m.isNew) t.hasUnread = true;
|
|
646
|
+
t.messages.push(m);
|
|
647
|
+
|
|
648
|
+
const msgTime = m.created ? new Date(m.created).getTime() : 0;
|
|
649
|
+
const latestTime = t.latestCreated ? new Date(t.latestCreated).getTime() : 0;
|
|
650
|
+
if (msgTime >= latestTime) {
|
|
651
|
+
t.latestCreated = m.created;
|
|
652
|
+
t.latestSnippet = m.snippet;
|
|
653
|
+
t.latestFrom = m.from;
|
|
654
|
+
if (m.subject) t.subject = m.subject;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
let threads = Array.from(threadMap.values()).map((t) => {
|
|
659
|
+
t.messages.sort((a, b) => {
|
|
660
|
+
const timeA = a.created ? new Date(a.created).getTime() : 0;
|
|
661
|
+
const timeB = b.created ? new Date(b.created).getTime() : 0;
|
|
662
|
+
return timeA - timeB;
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
const hasImage = t.messages.some((m) => m.hasImage);
|
|
666
|
+
const hasAttachment = t.messages.some((m) => m.hasAttachment);
|
|
667
|
+
|
|
668
|
+
return {
|
|
669
|
+
threadId: t.threadId,
|
|
670
|
+
subject: t.subject,
|
|
671
|
+
participants: Array.from(t.participants),
|
|
672
|
+
messageCount: t.messages.length,
|
|
673
|
+
hasUnread: t.hasUnread,
|
|
674
|
+
hasImage,
|
|
675
|
+
hasAttachment,
|
|
676
|
+
latestCreated: t.latestCreated,
|
|
677
|
+
latestSnippet: t.latestSnippet,
|
|
678
|
+
latestFrom: t.latestFrom,
|
|
679
|
+
messages: t.messages,
|
|
680
|
+
};
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
if (account !== "all") {
|
|
684
|
+
threads = threads.filter((t) => {
|
|
685
|
+
return (
|
|
686
|
+
t.participants.includes(account) ||
|
|
687
|
+
t.messages.some((m) => m.targetAccount === account || (m.to && m.to.includes(account)))
|
|
688
|
+
);
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// When freeform search query is absent, filter by the active folder
|
|
693
|
+
if (!query || !query.trim()) {
|
|
694
|
+
if (folder === "inbox") {
|
|
695
|
+
threads = threads.filter((t) => t.messages.some((m) => m.folder === "inbox"));
|
|
696
|
+
} else if (folder === "sent") {
|
|
697
|
+
threads = threads.filter((t) => {
|
|
698
|
+
if (account !== "all") {
|
|
699
|
+
return t.messages.some((m) => (m.from && m.from.toLowerCase() === account.toLowerCase()) || (m.folder === "sent" && m.targetAccount === account));
|
|
700
|
+
}
|
|
701
|
+
return t.messages.some((m) => m.folder === "sent" || m.from);
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (query && query.trim()) {
|
|
707
|
+
const parsedQuery = parseQuery(query, account, persona);
|
|
708
|
+
threads = threads.filter((t) => {
|
|
709
|
+
if (t.messages.some((m) => matchesFilter(m, parsedQuery))) return true;
|
|
710
|
+
if (parsedQuery.terms.length > 0) {
|
|
711
|
+
const combined = `${t.subject} ${t.threadId} ${t.participants.join(" ")}`;
|
|
712
|
+
return parsedQuery.terms.every((term) => matchFuzzyTerm(combined, term));
|
|
713
|
+
}
|
|
714
|
+
return false;
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
threads.sort((a, b) => {
|
|
719
|
+
const timeA = a.latestCreated ? new Date(a.latestCreated).getTime() : 0;
|
|
720
|
+
const timeB = b.latestCreated ? new Date(b.latestCreated).getTime() : 0;
|
|
721
|
+
return timeB - timeA;
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
const total = threads.length;
|
|
725
|
+
if (paginate) {
|
|
726
|
+
const p = Math.max(1, parseInt(page, 10) || 1);
|
|
727
|
+
const limit = Math.max(1, parseInt(pageSize, 10) || 50);
|
|
728
|
+
const start = (p - 1) * limit;
|
|
729
|
+
const items = threads.slice(start, start + limit);
|
|
730
|
+
return {
|
|
731
|
+
items,
|
|
732
|
+
total,
|
|
733
|
+
page: p,
|
|
734
|
+
pageSize: limit,
|
|
735
|
+
totalPages: Math.ceil(total / limit) || 1,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return threads;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
export function loadAgentDirectory(amqRoot) {
|
|
743
|
+
if (!amqRoot || !fs.existsSync(amqRoot)) return [];
|
|
744
|
+
|
|
745
|
+
const agentsDir = path.join(amqRoot, "agents");
|
|
746
|
+
if (!fs.existsSync(agentsDir)) return [];
|
|
747
|
+
|
|
748
|
+
const handles = fs.readdirSync(agentsDir).filter((h) => {
|
|
749
|
+
return !h.startsWith(".") && fs.statSync(path.join(agentsDir, h)).isDirectory();
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
// Query herdr agents if possible
|
|
753
|
+
const herdrAgents = new Map();
|
|
754
|
+
try {
|
|
755
|
+
const bin = getHerdrBin();
|
|
756
|
+
const out = execCmd(bin, ["pane", "list"]);
|
|
757
|
+
const panes = JSON.parse(out)?.result?.panes || [];
|
|
758
|
+
for (const p of panes) {
|
|
759
|
+
if (p.agent_status) {
|
|
760
|
+
const title = p.terminal_title_stripped || p.terminal_title || "";
|
|
761
|
+
for (const h of handles) {
|
|
762
|
+
if (title.includes(`- ${h} -`)) {
|
|
763
|
+
herdrAgents.set(h, {
|
|
764
|
+
status: p.agent_status,
|
|
765
|
+
paneId: p.pane_id,
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
} catch {}
|
|
772
|
+
|
|
773
|
+
const repoRoot = getRepoRootFromAmq(amqRoot);
|
|
774
|
+
const briefs = scanAgentBriefs(repoRoot);
|
|
775
|
+
|
|
776
|
+
// Merge discovered brief handles into the list so agents defined on disk are discoverable
|
|
777
|
+
for (const briefHandle of briefs.keys()) {
|
|
778
|
+
if (!handles.includes(briefHandle)) {
|
|
779
|
+
handles.push(briefHandle);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const list = [];
|
|
784
|
+
for (const h of handles) {
|
|
785
|
+
const presencePath = path.join(agentsDir, h, "presence.json");
|
|
786
|
+
let presence = null;
|
|
787
|
+
if (fs.existsSync(presencePath)) {
|
|
788
|
+
try {
|
|
789
|
+
presence = JSON.parse(fs.readFileSync(presencePath, "utf8"));
|
|
790
|
+
} catch {}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const profilePath = path.join(agentsDir, h, "profile.json");
|
|
794
|
+
let customProfile = null;
|
|
795
|
+
if (fs.existsSync(profilePath)) {
|
|
796
|
+
try {
|
|
797
|
+
customProfile = JSON.parse(fs.readFileSync(profilePath, "utf8"));
|
|
798
|
+
} catch {}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const herdrInfo = herdrAgents.get(h);
|
|
802
|
+
const status = herdrInfo?.status || presence?.status || "offline";
|
|
803
|
+
|
|
804
|
+
// Count unread messages
|
|
805
|
+
const newDir = path.join(agentsDir, h, "inbox", "new");
|
|
806
|
+
let unreadCount = 0;
|
|
807
|
+
if (fs.existsSync(newDir)) {
|
|
808
|
+
unreadCount = fs.readdirSync(newDir).filter((f) => f.endsWith(".md") || f.endsWith(".json")).length;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const brief = briefs.get(h) || null;
|
|
812
|
+
const name = customProfile?.name || presence?.name || brief?.name || formatAgentTitle(h);
|
|
813
|
+
const role = customProfile?.role || (presence?.role && presence.role !== "Swarm Agent" ? presence.role : null) || brief?.role || brief?.description || (h === "user" ? "Human Operator" : "Swarm Agent");
|
|
814
|
+
const model = customProfile?.model || brief?.model || "claude-3-7-sonnet";
|
|
815
|
+
const emoji = customProfile?.emoji || presence?.emoji || (h === "user" ? "👤" : h.slice(0, 1).toUpperCase());
|
|
816
|
+
const color = customProfile?.color || presence?.color || getAgentColor(h);
|
|
817
|
+
const worktree = customProfile?.worktree || null;
|
|
818
|
+
const prompt = customProfile?.prompt || brief?.prompt || "";
|
|
819
|
+
const briefSource = customProfile?.briefSource || brief?.source || null;
|
|
820
|
+
|
|
821
|
+
list.push({
|
|
822
|
+
handle: h,
|
|
823
|
+
status,
|
|
824
|
+
lastSeen: presence?.last_seen || null,
|
|
825
|
+
unreadCount,
|
|
826
|
+
profile: {
|
|
827
|
+
name,
|
|
828
|
+
emoji,
|
|
829
|
+
color,
|
|
830
|
+
role,
|
|
831
|
+
model,
|
|
832
|
+
worktree,
|
|
833
|
+
prompt,
|
|
834
|
+
briefSource,
|
|
835
|
+
},
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Sort alphabetically with active/working agents first
|
|
840
|
+
list.sort((a, b) => {
|
|
841
|
+
if (a.status === "working" && b.status !== "working") return -1;
|
|
842
|
+
if (b.status === "working" && a.status !== "working") return 1;
|
|
843
|
+
return a.handle.localeCompare(b.handle);
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
return list;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Register a new agent or update an existing agent profile with model configuration and prompt
|
|
851
|
+
*/
|
|
852
|
+
export function registerAgent(amqRoot, { handle, name, role, model = "claude-3-7-sonnet", emoji, color, worktree, prompt, brief, syncDisk = true }) {
|
|
853
|
+
if (!amqRoot || !fs.existsSync(amqRoot)) {
|
|
854
|
+
return { ok: false, error: "Invalid AMQ root" };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const safeHandle = (handle || "").trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
|
858
|
+
if (!safeHandle) {
|
|
859
|
+
return { ok: false, error: "Invalid agent handle" };
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const agentDir = path.join(amqRoot, "agents", safeHandle);
|
|
863
|
+
if (!fs.existsSync(agentDir)) {
|
|
864
|
+
// Create standard AMQ maildir structure
|
|
865
|
+
fs.mkdirSync(path.join(agentDir, "inbox", "new"), { recursive: true });
|
|
866
|
+
fs.mkdirSync(path.join(agentDir, "inbox", "cur"), { recursive: true });
|
|
867
|
+
fs.mkdirSync(path.join(agentDir, "inbox", "tmp"), { recursive: true });
|
|
868
|
+
fs.mkdirSync(path.join(agentDir, "outbox", "sent"), { recursive: true });
|
|
869
|
+
fs.mkdirSync(path.join(agentDir, "outbox", "tmp"), { recursive: true });
|
|
870
|
+
fs.mkdirSync(path.join(agentDir, "receipts"), { recursive: true });
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const promptContent = prompt || brief || undefined;
|
|
874
|
+
const profileData = {
|
|
875
|
+
handle: safeHandle,
|
|
876
|
+
name: name || formatAgentTitle(safeHandle),
|
|
877
|
+
role: role || "Autonomous Specialist",
|
|
878
|
+
model: model || "claude-3-7-sonnet",
|
|
879
|
+
emoji: emoji || (safeHandle === "user" ? "👤" : safeHandle.slice(0, 1).toUpperCase()),
|
|
880
|
+
color: color || getAgentColor(safeHandle),
|
|
881
|
+
worktree: worktree || null,
|
|
882
|
+
prompt: promptContent,
|
|
883
|
+
updatedAt: new Date().toISOString(),
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
fs.writeFileSync(
|
|
887
|
+
path.join(agentDir, "profile.json"),
|
|
888
|
+
JSON.stringify(profileData, null, 2),
|
|
889
|
+
"utf8"
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
// Sync to disk brief file if prompt is provided
|
|
893
|
+
if (syncDisk && promptContent) {
|
|
894
|
+
try {
|
|
895
|
+
const repoRoot = getRepoRootFromAmq(amqRoot);
|
|
896
|
+
saveAgentBrief(repoRoot, safeHandle, { description: role, prompt: promptContent, model, role });
|
|
897
|
+
} catch {}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
return { ok: true, agent: profileData };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Calculate disk space usage of .agent-mail in MB
|
|
905
|
+
*/
|
|
906
|
+
export function getStorageUsage(amqRoot) {
|
|
907
|
+
let totalBytes = 0;
|
|
908
|
+
function walk(dir) {
|
|
909
|
+
try {
|
|
910
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
911
|
+
for (const ent of entries) {
|
|
912
|
+
const full = path.join(dir, ent.name);
|
|
913
|
+
if (ent.isDirectory()) {
|
|
914
|
+
walk(full);
|
|
915
|
+
} else if (ent.isFile()) {
|
|
916
|
+
try {
|
|
917
|
+
totalBytes += fs.statSync(full).size;
|
|
918
|
+
} catch {}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
} catch {}
|
|
922
|
+
}
|
|
923
|
+
if (amqRoot && fs.existsSync(amqRoot)) {
|
|
924
|
+
walk(amqRoot);
|
|
925
|
+
}
|
|
926
|
+
const mb = totalBytes / (1024 * 1024);
|
|
927
|
+
return {
|
|
928
|
+
bytes: totalBytes,
|
|
929
|
+
mb: Number(mb.toFixed(1)),
|
|
930
|
+
display: `${mb.toFixed(1)} MB`,
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const VALID_AMQ_KINDS = new Set([
|
|
935
|
+
"brainstorm",
|
|
936
|
+
"review_request",
|
|
937
|
+
"review_response",
|
|
938
|
+
"question",
|
|
939
|
+
"answer",
|
|
940
|
+
"decision",
|
|
941
|
+
"status",
|
|
942
|
+
"todo",
|
|
943
|
+
]);
|
|
944
|
+
|
|
945
|
+
function normalizeAmqKind(rawKind) {
|
|
946
|
+
if (!rawKind) return null;
|
|
947
|
+
const k = String(rawKind).trim().toLowerCase();
|
|
948
|
+
if (VALID_AMQ_KINDS.has(k)) return k;
|
|
949
|
+
if (k === "task") return "todo";
|
|
950
|
+
if (k === "alert") return "status";
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Send an AMQ message using amq CLI or fallback to file creation
|
|
956
|
+
*/
|
|
957
|
+
/**
|
|
958
|
+
* Send an AMQ message using amq CLI or fallback to atomic Maildir delivery
|
|
959
|
+
*/
|
|
960
|
+
export function sendAmqMessage(amqRoot, { from, to, subject, body, thread, priority, kind, attachments }) {
|
|
961
|
+
const recipients = Array.isArray(to) ? to : [to];
|
|
962
|
+
const safeFrom = from || "coordinator";
|
|
963
|
+
const safeThread = thread || computeCanonicalThread(safeFrom, recipients);
|
|
964
|
+
const safePriority = priority || "normal";
|
|
965
|
+
const safeKind = normalizeAmqKind(kind);
|
|
966
|
+
const safeSubject = subject || "(no subject)";
|
|
967
|
+
|
|
968
|
+
const args = [
|
|
969
|
+
"send",
|
|
970
|
+
"--root",
|
|
971
|
+
amqRoot,
|
|
972
|
+
"--me",
|
|
973
|
+
safeFrom,
|
|
974
|
+
"--to",
|
|
975
|
+
recipients.join(","),
|
|
976
|
+
"--subject",
|
|
977
|
+
safeSubject,
|
|
978
|
+
];
|
|
979
|
+
|
|
980
|
+
if (safeThread) args.push("--thread", safeThread);
|
|
981
|
+
if (safePriority) args.push("--priority", safePriority);
|
|
982
|
+
if (safeKind) args.push("--kind", safeKind);
|
|
983
|
+
|
|
984
|
+
// Write body via temp file or arg
|
|
985
|
+
const tmpFile = path.join(os.tmpdir(), `amq_msg_${Date.now()}_${Math.random().toString(36).slice(2)}.txt`);
|
|
986
|
+
try {
|
|
987
|
+
fs.writeFileSync(tmpFile, body || "", "utf8");
|
|
988
|
+
args.push("--body", `@${tmpFile}`);
|
|
989
|
+
|
|
990
|
+
const out = execCmd("amq", args);
|
|
991
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
992
|
+
return { ok: true, output: out, method: "cli" };
|
|
993
|
+
} catch (err) {
|
|
994
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
995
|
+
|
|
996
|
+
// Pure JavaScript atomic Maildir delivery (DJB tmp -> new rename)
|
|
997
|
+
if (amqRoot && fs.existsSync(amqRoot)) {
|
|
998
|
+
try {
|
|
999
|
+
const result = sendMaildirMessage(amqRoot, {
|
|
1000
|
+
from: safeFrom,
|
|
1001
|
+
to: recipients,
|
|
1002
|
+
subject: safeSubject,
|
|
1003
|
+
body: body || "",
|
|
1004
|
+
thread: safeThread,
|
|
1005
|
+
priority: safePriority,
|
|
1006
|
+
kind: safeKind,
|
|
1007
|
+
attachments: attachments || [],
|
|
1008
|
+
});
|
|
1009
|
+
return { ok: true, msgId: result.id, method: "maildir_native" };
|
|
1010
|
+
} catch (fallbackErr) {
|
|
1011
|
+
return { ok: false, error: `${err.message} (native fallback failed: ${fallbackErr.message})` };
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
return { ok: false, error: err.message };
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Reply to an AMQ message by ID
|
|
1021
|
+
*/
|
|
1022
|
+
export function replyAmqMessage(amqRoot, { from, replyToId, body, subject, kind, attachments }) {
|
|
1023
|
+
const tmpFile = path.join(os.tmpdir(), `amq_reply_${Date.now()}_${Math.random().toString(36).slice(2)}.txt`);
|
|
1024
|
+
try {
|
|
1025
|
+
fs.writeFileSync(tmpFile, body || "", "utf8");
|
|
1026
|
+
const args = [
|
|
1027
|
+
"reply",
|
|
1028
|
+
"--root",
|
|
1029
|
+
amqRoot,
|
|
1030
|
+
"--me",
|
|
1031
|
+
from,
|
|
1032
|
+
"--id",
|
|
1033
|
+
replyToId,
|
|
1034
|
+
"--body",
|
|
1035
|
+
`@${tmpFile}`,
|
|
1036
|
+
];
|
|
1037
|
+
|
|
1038
|
+
const out = execCmd("amq", args);
|
|
1039
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
1040
|
+
return { ok: true, output: out, method: "cli" };
|
|
1041
|
+
} catch (err) {
|
|
1042
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
1043
|
+
|
|
1044
|
+
// Pure JavaScript RFC 5322 In-Reply-To chaining
|
|
1045
|
+
if (amqRoot && fs.existsSync(amqRoot)) {
|
|
1046
|
+
try {
|
|
1047
|
+
const result = replyMaildirMessage(amqRoot, {
|
|
1048
|
+
from,
|
|
1049
|
+
replyToId,
|
|
1050
|
+
body,
|
|
1051
|
+
subject,
|
|
1052
|
+
kind,
|
|
1053
|
+
attachments,
|
|
1054
|
+
});
|
|
1055
|
+
return { ok: true, msgId: result.id, method: "maildir_native" };
|
|
1056
|
+
} catch (nativeErr) {
|
|
1057
|
+
return { ok: false, error: `${err.message} (native reply failed: ${nativeErr.message})` };
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
return { ok: false, error: err.message };
|
|
1062
|
+
}
|
|
1063
|
+
}
|