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/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
+ }