@rubytech/create-maxy-code 0.1.485 → 0.1.486

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.
Files changed (91) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-21-graph-caption-resolver.md +124 -0
  3. package/payload/platform/docs/superpowers/plans/2026-07-21-task-1877-bash-schema-enforcement.md +107 -0
  4. package/payload/platform/docs/superpowers/plans/2026-07-21-task-1887-reconcile-allowed-top-level.md +205 -0
  5. package/payload/platform/lib/graph-style/dist/index.d.ts +4 -1
  6. package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
  7. package/payload/platform/lib/graph-style/dist/index.js +109 -1
  8. package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
  9. package/payload/platform/lib/graph-style/src/__tests__/caption-coverage.test.ts +117 -0
  10. package/payload/platform/lib/graph-style/src/index.ts +81 -1
  11. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard-bash.test.sh +159 -0
  12. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh +18 -6
  13. package/payload/platform/plugins/admin/hooks/fs-schema-guard-bash-post.sh +141 -0
  14. package/payload/platform/plugins/admin/hooks/fs-schema-guard-bash-pre.sh +66 -0
  15. package/payload/platform/plugins/admin/hooks/fs-schema-guard.sh +33 -1
  16. package/payload/platform/plugins/admin/skills/professional-document/SKILL.md +3 -3
  17. package/payload/platform/plugins/cloudflare/PLUGIN.md +2 -0
  18. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +23 -2
  19. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +2 -2
  20. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +49 -2
  21. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +57 -3
  22. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +44 -2
  23. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-session-grant.test.ts +67 -0
  24. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +42 -3
  25. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +10 -1
  26. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +19 -5
  27. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +19 -2
  28. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +8 -2
  29. package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +28 -5
  30. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +195 -6
  31. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +193 -10
  32. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +4 -3
  33. package/payload/platform/plugins/email/.claude-plugin/plugin.json +1 -1
  34. package/payload/platform/plugins/email/PLUGIN.md +5 -1
  35. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.d.ts +2 -0
  36. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.d.ts.map +1 -0
  37. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.js +50 -0
  38. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.js.map +1 -0
  39. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.d.ts +2 -0
  40. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.d.ts.map +1 -0
  41. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.js +51 -0
  42. package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.js.map +1 -0
  43. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.d.ts +2 -0
  44. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.d.ts.map +1 -0
  45. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.js +89 -0
  46. package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.js.map +1 -0
  47. package/payload/platform/plugins/email/mcp/dist/index.js +28 -0
  48. package/payload/platform/plugins/email/mcp/dist/index.js.map +1 -1
  49. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.d.ts +22 -0
  50. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.d.ts.map +1 -0
  51. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.js +55 -0
  52. package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.js.map +1 -0
  53. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +32 -0
  54. package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
  55. package/payload/platform/plugins/email/mcp/dist/lib/imap.js +75 -1
  56. package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
  57. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.d.ts +19 -0
  58. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.d.ts.map +1 -0
  59. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.js +49 -0
  60. package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.js.map +1 -0
  61. package/payload/platform/plugins/email/references/email-reference.md +7 -1
  62. package/payload/platform/plugins/memory/references/schema-construction.md +18 -12
  63. package/payload/platform/plugins/memory/references/schema-estate-agent.md +11 -6
  64. package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +75 -6
  65. package/payload/platform/scripts/lib/account-schema-owned-dirs.py +111 -22
  66. package/payload/platform/scripts/lib/provision-account-dir.sh +8 -1
  67. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts +39 -0
  68. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -0
  69. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +299 -0
  70. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -0
  71. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  72. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
  73. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  74. package/payload/platform/services/claude-session-manager/dist/index.js +15 -0
  75. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  76. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts.map +1 -1
  77. package/payload/platform/services/claude-session-manager/dist/system-prompt.js +4 -0
  78. package/payload/platform/services/claude-session-manager/dist/system-prompt.js.map +1 -1
  79. package/payload/platform/templates/account-schema/SCHEMA.md +17 -0
  80. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  81. package/payload/server/{chunk-XGNRSM57.js → chunk-HN4HFTXE.js} +6 -0
  82. package/payload/server/maxy-edge.js +1 -1
  83. package/payload/server/public/assets/{admin-rScqRq-R.js → admin-BoVCP4Mw.js} +1 -1
  84. package/payload/server/public/assets/{data-TOWuhMFb.js → data-CtOxa94P.js} +1 -1
  85. package/payload/server/public/assets/{graph-labels-Axa04w8A.js → graph-labels-C8-sIKuv.js} +1 -1
  86. package/payload/server/public/assets/{graph-BEGs_P2m.js → graph-mJ9ky5dc.js} +1 -1
  87. package/payload/server/public/assets/{page-6CZ8R57f.js → page-Cv0qUwMG.js} +1 -1
  88. package/payload/server/public/data.html +3 -3
  89. package/payload/server/public/graph.html +2 -2
  90. package/payload/server/public/index.html +3 -3
  91. package/payload/server/server.js +84 -1
@@ -19,6 +19,10 @@
19
19
  var sharedEl = document.getElementById('dp-shared');
20
20
  var crumbsEl = document.getElementById('dp-crumbs');
21
21
  var entriesEl = document.getElementById('dp-entries');
22
+ var upEl = document.getElementById('dp-up');
23
+ var uploadOpenEl = document.getElementById('dp-upload-open');
24
+ var modalEl = document.getElementById('dp-modal');
25
+ var modalCloseEl = document.getElementById('dp-modal-close');
22
26
 
23
27
  /* The folder currently open in the "shared with you" tree; '' is its root. */
24
28
  var prefix = '';
@@ -38,6 +42,55 @@
38
42
  return li;
39
43
  }
40
44
 
45
+ /* Lucide folder/file icons, inlined as trusted literals — no network request,
46
+ no npm dependency. innerHTML is safe here because the markup is fixed and
47
+ carries no filename or device data; every name still goes through
48
+ textContent. The stroke is currentColor, so each icon takes its span's
49
+ colour (folder accent, file muted; see portal.css). */
50
+ var FOLDER_SVG =
51
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>';
52
+ var FILE_SVG =
53
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg>';
54
+
55
+ function icon(kind) {
56
+ var s = document.createElement('span');
57
+ var dir = kind === 'directory';
58
+ s.className = dir ? 'dp-icon dp-icon-folder' : 'dp-icon dp-icon-file';
59
+ s.innerHTML = dir ? FOLDER_SVG : FILE_SVG;
60
+ return s;
61
+ }
62
+
63
+ /* Split a filename into a stem and its extension. The stem is everything up to
64
+ the last dot; the extension is the dot and what follows. A name with no dot,
65
+ or only a leading dot, is all stem. The two render as separate spans so the
66
+ stem can ellipsis-truncate while the extension stays pinned and visible. */
67
+ function nameParts(name) {
68
+ var dot = name.lastIndexOf('.');
69
+ if (dot > 0) {
70
+ return { main: name.slice(0, dot), ext: name.slice(dot) };
71
+ }
72
+ return { main: name, ext: '' };
73
+ }
74
+
75
+ /* The two-span name block used by both lists. Every value goes through
76
+ textContent, never markup — the same trust rule the callers already follow.
77
+ An empty ext adds no second span. */
78
+ function nameNode(main, ext) {
79
+ var wrap = document.createElement('span');
80
+ wrap.className = 'dp-name';
81
+ var m = document.createElement('span');
82
+ m.className = 'dp-name-main';
83
+ m.textContent = main;
84
+ wrap.appendChild(m);
85
+ if (ext) {
86
+ var e = document.createElement('span');
87
+ e.className = 'dp-name-ext';
88
+ e.textContent = ext;
89
+ wrap.appendChild(e);
90
+ }
91
+ return wrap;
92
+ }
93
+
41
94
  function render(files) {
42
95
  // replaceChildren, not innerHTML: every filename below is set with
43
96
  // textContent so a name like '<img onerror=…>' renders as text, and
@@ -54,7 +107,10 @@
54
107
  var a = document.createElement('a');
55
108
  a.className = 'dp-file-name';
56
109
  a.href = API + '/api/download?key=' + encodeURIComponent(f.objectKey);
57
- a.textContent = f.filename;
110
+ // Full name on hover, since the middle of a long name is clipped away.
111
+ a.title = f.filename;
112
+ var p = nameParts(f.filename);
113
+ a.appendChild(nameNode(p.main, p.ext));
58
114
  li.appendChild(a);
59
115
 
60
116
  var del = document.createElement('button');
@@ -92,6 +148,46 @@
92
148
  crumbsEl.appendChild(document.createTextNode(' / '));
93
149
  crumbsEl.appendChild(crumb(part, parts.slice(0, i + 1).join('/')));
94
150
  });
151
+ // The up control has no parent to reach from the root, so it is inert there.
152
+ upEl.disabled = prefix === '';
153
+ }
154
+
155
+ /* One right-click menu at a time. A single element is moved and re-labelled
156
+ rather than spawning one per row, so dismissal has one thing to hide. */
157
+ var menuEl = null;
158
+ function hideMenu() {
159
+ if (menuEl) { menuEl.hidden = true; }
160
+ }
161
+ function showFileMenu(x, y, relPath) {
162
+ if (!menuEl) {
163
+ menuEl = document.createElement('div');
164
+ menuEl.className = 'dp-menu';
165
+ menuEl.hidden = true;
166
+ document.body.appendChild(menuEl);
167
+ }
168
+ menuEl.replaceChildren();
169
+ var b = document.createElement('button');
170
+ b.type = 'button';
171
+ b.className = 'dp-menu-item';
172
+ b.textContent = 'Download';
173
+ b.addEventListener('click', function () {
174
+ hideMenu();
175
+ openShared(relPath);
176
+ });
177
+ menuEl.appendChild(b);
178
+ menuEl.style.left = x + 'px';
179
+ menuEl.style.top = y + 'px';
180
+ menuEl.hidden = false;
181
+ }
182
+ document.addEventListener('click', hideMenu);
183
+ window.addEventListener('blur', hideMenu);
184
+
185
+ /* The endpoint a drag-out points at. redirect=1 makes it 302 to the signed
186
+ link, so the browser downloads the file rather than a JSON body. Resolved to
187
+ an absolute URL (DownloadURL requires one) against the page, so it is correct
188
+ whether api-base is empty, a path, or a full origin. */
189
+ function downloadUrl(relPath) {
190
+ return new URL(API + '/api/download?path=' + encodeURIComponent(relPath) + '&redirect=1', location.href).href;
95
191
  }
96
192
 
97
193
  /* Every label goes through textContent, same rule as the upload list: these
@@ -110,7 +206,13 @@
110
206
  var b = document.createElement('button');
111
207
  b.className = e.kind === 'directory' ? 'dp-folder-name' : 'dp-file-name';
112
208
  b.type = 'button';
113
- b.textContent = e.kind === 'directory' ? e.name + '/' : e.name;
209
+ // Full name on hover, since the middle of a long name is clipped away.
210
+ b.title = e.name;
211
+ // A folder's tail is its trailing slash; a file's is its extension. Both
212
+ // stay pinned while the stem truncates.
213
+ var np = e.kind === 'directory' ? { main: e.name, ext: '/' } : nameParts(e.name);
214
+ b.appendChild(icon(e.kind));
215
+ b.appendChild(nameNode(np.main, np.ext));
114
216
  b.addEventListener('click', function () {
115
217
  if (e.kind === 'directory') {
116
218
  prefix = e.relPath;
@@ -119,6 +221,20 @@
119
221
  openShared(e.relPath);
120
222
  }
121
223
  });
224
+ if (e.kind !== 'directory') {
225
+ b.draggable = true;
226
+ b.addEventListener('dragstart', function (ev) {
227
+ var url = downloadUrl(e.relPath);
228
+ // DownloadURL is "mime:filename:absolute-url"; octet-stream keeps the
229
+ // server's content-disposition filename authoritative.
230
+ ev.dataTransfer.setData('DownloadURL', 'application/octet-stream:' + e.name + ':' + url);
231
+ ev.dataTransfer.setData('text/uri-list', url);
232
+ });
233
+ b.addEventListener('contextmenu', function (ev) {
234
+ ev.preventDefault();
235
+ showFileMenu(ev.clientX, ev.clientY, e.relPath);
236
+ });
237
+ }
122
238
  li.appendChild(b);
123
239
  entriesEl.appendChild(li);
124
240
  });
@@ -244,24 +360,91 @@
244
360
  revealEl.textContent = revealed ? 'Show passcode' : 'Hide passcode';
245
361
  });
246
362
 
247
- document.getElementById('dp-upload').addEventListener('submit', function (e) {
248
- e.preventDefault();
249
- var input = document.getElementById('dp-file');
250
- if (!input.files || !input.files[0]) return;
363
+ /* Up one level: drop the last path segment and re-list. Inert at the root,
364
+ where the button is disabled, so this guard is belt-and-braces. */
365
+ upEl.addEventListener('click', function () {
366
+ if (prefix === '') return;
367
+ var parts = prefix.split('/');
368
+ parts.pop();
369
+ prefix = parts.join('/');
370
+ refresh();
371
+ });
372
+
373
+ /* The upload modal. Hidden via the `hidden` attribute so
374
+ [hidden]{display:none!important} stays authoritative over .dp-modal's
375
+ display:flex. It dismisses three ways: the close button, a click on the
376
+ backdrop (never the panel), and Escape while open. */
377
+ function openModal() {
378
+ modalEl.hidden = false;
379
+ document.getElementById('dp-file').focus();
380
+ }
381
+ function closeModal() {
382
+ modalEl.hidden = true;
383
+ }
384
+ uploadOpenEl.addEventListener('click', openModal);
385
+ modalCloseEl.addEventListener('click', closeModal);
386
+ modalEl.addEventListener('click', function (e) {
387
+ if (e.target === modalEl) { closeModal(); }
388
+ });
389
+ document.addEventListener('keydown', function (e) {
390
+ if (e.key === 'Escape' && !modalEl.hidden) { closeModal(); }
391
+ });
392
+
393
+ var uploadForm = document.getElementById('dp-upload');
394
+
395
+ /* One upload path for Browse-and-Upload and for a dropped file. Resolves to
396
+ { responded, ok }: `responded` is true when the server answered (kept or
397
+ not), so the picker is cleared only when a retry with the same selection is
398
+ pointless; `ok` is true only on a kept upload, which is what closes the
399
+ modal. A network error is { responded:false, ok:false }. */
400
+ function uploadFile(file) {
251
401
  var fd = new FormData();
252
- fd.append('file', input.files[0]);
402
+ fd.append('file', file);
253
403
  say('Uploading…');
254
- api('/api/upload', { method: 'POST', body: fd })
404
+ return api('/api/upload', { method: 'POST', body: fd })
255
405
  .then(function (r) {
256
406
  return r.json();
257
407
  })
258
408
  .then(function (d) {
259
409
  say(d.ok ? 'Uploaded.' : 'Upload failed.');
260
- input.value = '';
261
- return refresh();
410
+ return refresh().then(function () { return { responded: true, ok: !!d.ok }; });
262
411
  })
263
412
  .catch(function () {
264
413
  say('Upload failed.');
414
+ return { responded: false, ok: false };
415
+ });
416
+ }
417
+
418
+ uploadForm.addEventListener('submit', function (e) {
419
+ e.preventDefault();
420
+ var input = document.getElementById('dp-file');
421
+ if (!input.files || !input.files[0]) return;
422
+ uploadFile(input.files[0]).then(function (res) {
423
+ if (res.responded) { input.value = ''; }
424
+ if (res.ok) { closeModal(); }
425
+ });
426
+ });
427
+
428
+ uploadForm.addEventListener('dragover', function (e) {
429
+ e.preventDefault();
430
+ uploadForm.classList.add('dp-dropping');
431
+ });
432
+ uploadForm.addEventListener('dragleave', function (e) {
433
+ // Only clear when the pointer leaves the form, not when it crosses onto a
434
+ // child (input, button), which would flicker the outline mid-drag.
435
+ if (!uploadForm.contains(e.relatedTarget)) {
436
+ uploadForm.classList.remove('dp-dropping');
437
+ }
438
+ });
439
+ uploadForm.addEventListener('drop', function (e) {
440
+ e.preventDefault();
441
+ uploadForm.classList.remove('dp-dropping');
442
+ var dt = e.dataTransfer;
443
+ if (dt && dt.files && dt.files[0]) {
444
+ // The drop target now lives inside the modal, so a kept drop closes it too.
445
+ uploadFile(dt.files[0]).then(function (res) {
446
+ if (res.ok) { closeModal(); }
265
447
  });
448
+ }
266
449
  });
267
450
  })();
@@ -4,7 +4,7 @@
4
4
  # __D1_DATABASE_NAME__ the D1 database name (<accountId>-<purpose>)
5
5
  # __D1_DATABASE_ID__ the id printed by `wrangler d1 create`
6
6
  # __R2_BUCKET_NAME__ the R2 bucket name (<accountId>-<purpose>)
7
- # __PORTAL_INSTALL_ORIGIN__ the install's public origin (https://<tunnel-hostname>)
7
+ # __PORTAL_INSTALL_ORIGIN__ the install's PUBLIC origin (https://<public-tunnel-hostname>), NOT the admin/operator host
8
8
  name = "__PROJECT_NAME__"
9
9
  pages_build_output_dir = "."
10
10
  compatibility_date = "2024-01-01"
@@ -22,8 +22,9 @@ binding = "BUCKET"
22
22
  bucket_name = "__R2_BUCKET_NAME__"
23
23
 
24
24
  # Where the install answers, so a download of a device-held file can be signed
25
- # and sent there. Public information it is the hostname a client's browser
26
- # ends up at.
25
+ # and sent there. Public information: it is the hostname a client's browser
26
+ # ends up at. Use the PUBLIC host: the admin host is remote-auth-gated and the
27
+ # edge's signed HEAD probe 401s there, which 503s every download.
27
28
  #
28
29
  # Its partner, PORTAL_FETCH_SECRET, is deliberately NOT here. This file is
29
30
  # committed to the account's assembled tree and the deploy publishes the
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "email",
3
- "description": "Email — one or more dedicated email mailboxes per account, each addressable by a `mailbox` selector. Retrieval: email-graph-query for stored email-thread history as :ConversationArchive {source:'email'} with :Section chunks (recall, search, 'threads about X', 'what did Y send?'); email-read / email-search for live IMAP inbox previews (with best-effort per-archive section hints from the graph); email-fetch-body for the COMPLETE decoded body of one message (by uid or message_id) when a preview is not enough. Ingestion (operator-initiated, human-in-the-loop): email-fetch lists new messages, email-ingest writes the operator-approved subset via the conversation-archive pipeline. Sending: email-send (new), email-reply (threaded — resolves the original via IMAP Message-ID search), email-draft (compose to the mailbox Drafts folder, unsent, for operator review; pass messageId for a threaded draft-reply), email-draft-edit (revise a stored draft, located by its Drafts UID or, when Gmail has renumbered it, its draft Message-ID), email-draft-send (dispatch a stored draft by Drafts UID, then remove it). Deleting: email-delete (move named UIDs from email-read/email-search to the mailbox Trash — recoverable, never an expunge). Config: email-provider-info (pre-password host + app-password lookup from an address alone), email-setup, email-status, email-otp-extract.",
3
+ "description": "Email — one or more dedicated email mailboxes per account, each addressable by a `mailbox` selector. Retrieval: email-graph-query for stored email-thread history as :ConversationArchive {source:'email'} with :Section chunks (recall, search, 'threads about X', 'what did Y send?'); email-read / email-search for live IMAP inbox previews (with best-effort per-archive section hints from the graph); email-fetch-body for the COMPLETE decoded body of one message (by uid or message_id) when a preview is not enough; email-fetch-attachment for the extracted TEXT of one attachment (PDF via pdftotext, text/* by decode) when data sits inside a file rather than the body. Ingestion (operator-initiated, human-in-the-loop): email-fetch lists new messages, email-ingest writes the operator-approved subset via the conversation-archive pipeline. Sending: email-send (new), email-reply (threaded — resolves the original via IMAP Message-ID search), email-draft (compose to the mailbox Drafts folder, unsent, for operator review; pass messageId for a threaded draft-reply), email-draft-edit (revise a stored draft, located by its Drafts UID or, when Gmail has renumbered it, its draft Message-ID), email-draft-send (dispatch a stored draft by Drafts UID, then remove it). Deleting: email-delete (move named UIDs from email-read/email-search to the mailbox Trash — recoverable, never an expunge). Config: email-provider-info (pre-password host + app-password lookup from an address alone), email-setup, email-status, email-otp-extract.",
4
4
  "version": "0.1.0",
5
5
  "author": {
6
6
  "name": "Rubytech LLC"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: email
3
- description: "Email — one or more dedicated email mailboxes per account, each addressable by a `mailbox` selector. Retrieval: email-graph-query for stored email-thread history as :ConversationArchive {source:'email'} with :Section chunks (recall, search, 'threads about X', 'what did Y send?'); email-read / email-search for live IMAP inbox previews (with best-effort per-archive section hints from the graph); email-fetch-body for the COMPLETE decoded body of one message (by uid or message_id) when a preview is not enough. Ingestion (operator-initiated, human-in-the-loop): email-fetch lists new messages, email-ingest writes the operator-approved subset via the conversation-archive pipeline. Sending: email-send (new), email-reply (threaded — resolves the original via IMAP Message-ID search), email-draft (compose to the mailbox Drafts folder, unsent, for operator review; pass messageId for a threaded draft-reply), email-draft-edit (revise a stored draft, located by its Drafts UID or, when Gmail has renumbered it, its draft Message-ID), email-draft-send (dispatch a stored draft by Drafts UID, then remove it). Deleting: email-delete (move named UIDs from email-read/email-search to the mailbox Trash — recoverable, never an expunge). Config: email-provider-info (pre-password host + app-password lookup from an address alone), email-setup, email-status, email-otp-extract."
3
+ description: "Email — one or more dedicated email mailboxes per account, each addressable by a `mailbox` selector. Retrieval: email-graph-query for stored email-thread history as :ConversationArchive {source:'email'} with :Section chunks (recall, search, 'threads about X', 'what did Y send?'); email-read / email-search for live IMAP inbox previews (with best-effort per-archive section hints from the graph); email-fetch-body for the COMPLETE decoded body of one message (by uid or message_id) when a preview is not enough; email-fetch-attachment for the extracted TEXT of one attachment (PDF via pdftotext, text/* by decode) when data sits inside a file rather than the body. Ingestion (operator-initiated, human-in-the-loop): email-fetch lists new messages, email-ingest writes the operator-approved subset via the conversation-archive pipeline. Sending: email-send (new), email-reply (threaded — resolves the original via IMAP Message-ID search), email-draft (compose to the mailbox Drafts folder, unsent, for operator review; pass messageId for a threaded draft-reply), email-draft-edit (revise a stored draft, located by its Drafts UID or, when Gmail has renumbered it, its draft Message-ID), email-draft-send (dispatch a stored draft by Drafts UID, then remove it). Deleting: email-delete (move named UIDs from email-read/email-search to the mailbox Trash — recoverable, never an expunge). Config: email-provider-info (pre-password host + app-password lookup from an address alone), email-setup, email-status, email-otp-extract."
4
4
  tools:
5
5
  - name: email-provider-info
6
6
  publicAllowlist: false
@@ -14,6 +14,9 @@ tools:
14
14
  - name: email-fetch-body
15
15
  publicAllowlist: false
16
16
  adminAllowlist: false
17
+ - name: email-fetch-attachment
18
+ publicAllowlist: false
19
+ adminAllowlist: false
17
20
  - name: email-send
18
21
  publicAllowlist: false
19
22
  adminAllowlist: false
@@ -84,6 +87,7 @@ Manages the agent's own dedicated email account — IMAP for reading, SMTP for s
84
87
  - **Read inbox:** `email-read` — metadata only (UID, sender, subject, date), plus a best-effort `Archive`/`Archive-Section` hint when the IMAP message's `receivedAt` falls inside a stored `:ConversationArchive {source:'email'}` section, plus an `Attachments (N): filename (mime, size); …` footer when the message carries enclosures. Supports pagination (`before_uid`), folders (`inbox`/`sent`), filtering by sender/date/subject. Metadata is derived from IMAP `bodyStructure` — no bytes downloaded.
85
88
  - **Search inbox:** `email-search` — live IMAP search by sender, subject, body keyword, date range. Same per-archive hint and attachment footer as `email-read`.
86
89
  - **Delete (move to Trash):** `email-delete` — move the messages named by `uids` (from a prior `email-read`/`email-search`, scoped to a source `folder` of `inbox` or `sent`) into the mailbox's Trash folder. **Recoverable, never an expunge** — the message leaves the inbox and permanent removal is left to the operator's own mail client or the provider's Trash auto-purge; this tool never permanently deletes. Trash is resolved per provider: the `\Trash` special-use folder if advertised, else the first existing of `Trash`, `[Gmail]/Trash`, `Deleted Items`, `Deleted Messages`. `Junk` is never treated as Trash. A mailbox with no resolvable Trash yields a typed error and moves nothing — there is no silent expunge fallback. A `uid` that is already gone is counted not-moved without failing the rest; the result reports `Moved N of M message(s) to <Trash>`. It deletes only the UIDs you name, never by search criteria.
90
+ - **Read one attachment's text:** `email-fetch-attachment` — the extracted text of a single inbox attachment, addressed by `uid` plus an optional `index` (1-based, matching the `Attachments (N): …` footer order) or `filename`. With one attachment and no selector, that attachment is read; with more than one and no selector, or an ambiguous filename, the call is refused with a numbered listing. `application/pdf` is extracted via `pdftotext`, `text/*` by decode; any other type reports that it cannot be extracted, and a PDF with no text layer reports it is a scanned image (no OCR), so "no data in the attachment" never reads the same as "the attachment could not be read". Bytes are downloaded once through the 25 MB-capped fetch path; inbox only. Use this when actionable data (a meeting date, an amount) may sit inside a PDF rather than the email body.
87
91
  - **Read one whole message:** `email-fetch-body` — the COMPLETE decoded body of a single message addressed by `uid` (with optional `folder`) or `message_id` (resolved across inbox and sent). No 500-char preview cap. Downloads the full RFC822 source and decodes it with `mailparser`, so every MIME shape — flat text, single-part HTML, `multipart/alternative`, and nested `multipart/related`/`multipart/mixed` (Outlook's inline-image house style) — yields a body; the preview tools above intentionally stay preview-only. Use this when the operator wants to read or act on a message's actual content.
88
92
  - **Send:** `email-send` — new outbound email. Pass `cc`/`bcc` (each an array of email addresses) to copy or blind-copy recipients — CC appears in the message header, BCC recipients receive the message without appearing in any header. Pass `attachments` (absolute file paths under the account directory, ≤25 MB each) to attach files as SMTP attachments; an invalid path fails the send with no email dispatched. The success message names the CC recipients and reports the BCC count without naming them.
89
93
  - **Reply:** `email-reply` — threaded reply to an existing email by `messageId`. Resolves the original envelope via IMAP `SEARCH HEADER Message-ID` (INBOX then `\Sent`), so threading works for any message still present on the server even if the graph never ingested it. Supports `replyAll`, `cc`/`bcc`, and `attachments` (same path rules as `email-send`). Explicit `cc`/`bcc` are added in addition to whatever `replyAll` places in `To`, passed through as given (no overlap de-duplication against `To`).
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=attachment-select.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachment-select.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/attachment-select.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { resolveAttachmentSelector } from "../lib/imap.js";
3
+ const A = { partKey: "2", filename: "a.pdf", mimeType: "application/pdf", sizeBytes: 10, encoding: "base64" };
4
+ const B = { partKey: "3", filename: "b.pdf", mimeType: "application/pdf", sizeBytes: 20, encoding: "base64" };
5
+ describe("resolveAttachmentSelector", () => {
6
+ it("single attachment, no selector → that one", () => {
7
+ expect(resolveAttachmentSelector([A], {})).toEqual({ ok: true, meta: A });
8
+ });
9
+ it("multiple attachments, no selector → error listing them", () => {
10
+ const r = resolveAttachmentSelector([A, B], {});
11
+ expect(r.ok).toBe(false);
12
+ if (!r.ok) {
13
+ expect(r.error).toMatch(/1\. a\.pdf/);
14
+ expect(r.error).toMatch(/2\. b\.pdf/);
15
+ }
16
+ });
17
+ it("index selects the right attachment", () => {
18
+ expect(resolveAttachmentSelector([A, B], { index: 2 })).toEqual({ ok: true, meta: B });
19
+ });
20
+ it("index out of range → error listing", () => {
21
+ const r = resolveAttachmentSelector([A], { index: 2 });
22
+ expect(r.ok).toBe(false);
23
+ if (!r.ok)
24
+ expect(r.error).toMatch(/index 2/);
25
+ });
26
+ it("filename match is case-insensitive", () => {
27
+ expect(resolveAttachmentSelector([A, B], { filename: "B.PDF" })).toEqual({ ok: true, meta: B });
28
+ });
29
+ it("filename with no match → error", () => {
30
+ const r = resolveAttachmentSelector([A, B], { filename: "c.pdf" });
31
+ expect(r.ok).toBe(false);
32
+ if (!r.ok)
33
+ expect(r.error).toMatch(/no attachment named/i);
34
+ });
35
+ it("filename matching two parts → ambiguous error", () => {
36
+ const dup = { ...B, filename: "a.pdf" };
37
+ const r = resolveAttachmentSelector([A, dup], { filename: "a.pdf" });
38
+ expect(r.ok).toBe(false);
39
+ if (!r.ok)
40
+ expect(r.error).toMatch(/more than one/i);
41
+ });
42
+ it("no attachments → error", () => {
43
+ const r = resolveAttachmentSelector([], {});
44
+ expect(r.ok).toBe(false);
45
+ });
46
+ it("index takes precedence over filename when both given", () => {
47
+ expect(resolveAttachmentSelector([A, B], { index: 1, filename: "b.pdf" })).toEqual({ ok: true, meta: A });
48
+ });
49
+ });
50
+ //# sourceMappingURL=attachment-select.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachment-select.test.js","sourceRoot":"","sources":["../../src/__tests__/attachment-select.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=attachment-text.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachment-text.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/attachment-text.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ // child_process is mocked so the PDF path is testable without a real pdftotext
3
+ // binary or a real PDF. text/* needs no mock.
4
+ // execFileMock returns either { stdout } (success) or { error } (spawn failure).
5
+ // The mocked execFile routes it through promisify's node-style callback, so no
6
+ // synchronous throw ever escapes into the test runner.
7
+ const execFileMock = vi.fn();
8
+ vi.mock("node:child_process", () => ({
9
+ execFile: (...args) => {
10
+ const cb = args[args.length - 1];
11
+ const outcome = execFileMock(args[0], args[1]);
12
+ if (outcome.error)
13
+ cb(outcome.error);
14
+ else
15
+ cb(null, { stdout: outcome.stdout ?? "" });
16
+ },
17
+ }));
18
+ import { extractAttachmentText } from "../lib/attachment-text.js";
19
+ describe("extractAttachmentText", () => {
20
+ beforeEach(() => execFileMock.mockReset());
21
+ it("decodes text/plain", async () => {
22
+ const r = await extractAttachmentText(Buffer.from(" meeting at 3pm "), "text/plain");
23
+ expect(r.outcome).toBe("extracted");
24
+ expect(r.text).toBe("meeting at 3pm");
25
+ expect(r.mimeType).toBe("text/plain");
26
+ });
27
+ it("returns unsupported-mime for images", async () => {
28
+ const r = await extractAttachmentText(Buffer.from([0x89, 0x50]), "image/png");
29
+ expect(r.outcome).toBe("unsupported-mime");
30
+ expect(r.text).toBe("");
31
+ });
32
+ it("extracts a PDF via pdftotext", async () => {
33
+ execFileMock.mockReturnValue({ stdout: "Tuesday Sep 8, 2026 10:30am" });
34
+ const r = await extractAttachmentText(Buffer.from("%PDF-1.4"), "application/pdf");
35
+ expect(execFileMock).toHaveBeenCalledOnce();
36
+ expect(execFileMock.mock.calls[0][0]).toBe("pdftotext");
37
+ expect(r.outcome).toBe("extracted");
38
+ expect(r.text).toContain("Sep 8, 2026");
39
+ });
40
+ it("reports no-text-layer when a PDF yields no text", async () => {
41
+ execFileMock.mockReturnValue({ stdout: " " });
42
+ const r = await extractAttachmentText(Buffer.from("%PDF-1.4"), "application/pdf");
43
+ expect(r.outcome).toBe("no-text-layer");
44
+ expect(r.text).toBe("");
45
+ });
46
+ it("surfaces a poppler-install hint when pdftotext is missing", async () => {
47
+ execFileMock.mockReturnValue({ error: new Error("spawn pdftotext ENOENT") });
48
+ await expect(extractAttachmentText(Buffer.from("%PDF-1.4"), "application/pdf")).rejects.toThrow(/poppler-utils/);
49
+ });
50
+ });
51
+ //# sourceMappingURL=attachment-text.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachment-text.test.js","sourceRoot":"","sources":["../../src/__tests__/attachment-text.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=email-fetch-attachment.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email-fetch-attachment.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/email-fetch-attachment.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ const loadCredentials = vi.fn();
3
+ const fetchMessageAttachments = vi.fn();
4
+ const fetchAttachmentBytesForMessages = vi.fn();
5
+ const extractAttachmentText = vi.fn();
6
+ vi.mock("../lib/credentials.js", () => ({
7
+ loadCredentials: (...a) => loadCredentials(...a),
8
+ }));
9
+ // Keep the real resolveAttachmentSelector (pure, already tested); override only
10
+ // the two network-bound functions.
11
+ vi.mock("../lib/imap.js", async (importActual) => {
12
+ const actual = await importActual();
13
+ return {
14
+ ...actual,
15
+ fetchMessageAttachments: (...a) => fetchMessageAttachments(...a),
16
+ fetchAttachmentBytesForMessages: (...a) => fetchAttachmentBytesForMessages(...a),
17
+ };
18
+ });
19
+ vi.mock("../lib/attachment-text.js", () => ({
20
+ extractAttachmentText: (...a) => extractAttachmentText(...a),
21
+ }));
22
+ import { emailFetchAttachment } from "../tools/email-fetch-attachment.js";
23
+ const PDF = { partKey: "2", filename: "proposal.pdf", mimeType: "application/pdf", sizeBytes: 12345, encoding: "base64" };
24
+ const PDF2 = { partKey: "3", filename: "terms.pdf", mimeType: "application/pdf", sizeBytes: 6789, encoding: "base64" };
25
+ beforeEach(() => {
26
+ loadCredentials.mockReset();
27
+ fetchMessageAttachments.mockReset();
28
+ fetchAttachmentBytesForMessages.mockReset();
29
+ extractAttachmentText.mockReset();
30
+ loadCredentials.mockResolvedValue({ credentials: { config: { email: "info@x.com" }, password: "pw" } });
31
+ });
32
+ describe("emailFetchAttachment", () => {
33
+ it("returns the extracted text with a header for a PDF", async () => {
34
+ fetchMessageAttachments.mockResolvedValue({ found: true, attachments: [PDF] });
35
+ fetchAttachmentBytesForMessages.mockResolvedValue([{ uid: 99, partKey: "2", bytes: Buffer.from("x"), failureReason: null }]);
36
+ extractAttachmentText.mockResolvedValue({ outcome: "extracted", text: "Meeting: Tue Sep 8 10:30", mimeType: "application/pdf" });
37
+ const out = await emailFetchAttachment({ accountId: "acc", uid: 99 });
38
+ expect(out).toMatch(/Attachment: proposal\.pdf/);
39
+ expect(out).toMatch(/part 2/);
40
+ expect(out).toMatch(/Meeting: Tue Sep 8 10:30/);
41
+ });
42
+ it("refuses with a listing when two attachments and no selector", async () => {
43
+ fetchMessageAttachments.mockResolvedValue({ found: true, attachments: [PDF, PDF2] });
44
+ const out = await emailFetchAttachment({ accountId: "acc", uid: 99 });
45
+ expect(out).toMatch(/pass index= or filename=/);
46
+ expect(out).toMatch(/1\. proposal\.pdf/);
47
+ expect(fetchAttachmentBytesForMessages).not.toHaveBeenCalled();
48
+ });
49
+ it("reports a scanned PDF with no text layer", async () => {
50
+ fetchMessageAttachments.mockResolvedValue({ found: true, attachments: [PDF] });
51
+ fetchAttachmentBytesForMessages.mockResolvedValue([{ uid: 99, partKey: "2", bytes: Buffer.from("x"), failureReason: null }]);
52
+ extractAttachmentText.mockResolvedValue({ outcome: "no-text-layer", text: "", mimeType: "application/pdf" });
53
+ const out = await emailFetchAttachment({ accountId: "acc", uid: 99 });
54
+ expect(out).toMatch(/no extractable text layer/);
55
+ expect(out).toMatch(/OCR is not available/);
56
+ });
57
+ it("reports unsupported mime", async () => {
58
+ const docx = { partKey: "2", filename: "x.docx", mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", sizeBytes: 10, encoding: "base64" };
59
+ fetchMessageAttachments.mockResolvedValue({ found: true, attachments: [docx] });
60
+ fetchAttachmentBytesForMessages.mockResolvedValue([{ uid: 99, partKey: "2", bytes: Buffer.from("x"), failureReason: null }]);
61
+ extractAttachmentText.mockResolvedValue({ outcome: "unsupported-mime", text: "", mimeType: docx.mimeType });
62
+ const out = await emailFetchAttachment({ accountId: "acc", uid: 99 });
63
+ expect(out).toMatch(/cannot be extracted from this type/i);
64
+ });
65
+ it("reports an oversize attachment", async () => {
66
+ fetchMessageAttachments.mockResolvedValue({ found: true, attachments: [PDF] });
67
+ fetchAttachmentBytesForMessages.mockResolvedValue([{ uid: 99, partKey: "2", bytes: null, failureReason: "oversize" }]);
68
+ const out = await emailFetchAttachment({ accountId: "acc", uid: 99 });
69
+ expect(out).toMatch(/25 MB/);
70
+ expect(extractAttachmentText).not.toHaveBeenCalled();
71
+ });
72
+ it("reports a download failure", async () => {
73
+ fetchMessageAttachments.mockResolvedValue({ found: true, attachments: [PDF] });
74
+ fetchAttachmentBytesForMessages.mockResolvedValue([{ uid: 99, partKey: "2", bytes: null, failureReason: "fetch-error" }]);
75
+ const out = await emailFetchAttachment({ accountId: "acc", uid: 99 });
76
+ expect(out).toMatch(/Could not download/);
77
+ });
78
+ it("reports message not found", async () => {
79
+ fetchMessageAttachments.mockResolvedValue({ found: false, attachments: [] });
80
+ const out = await emailFetchAttachment({ accountId: "acc", uid: 404 });
81
+ expect(out).toMatch(/No message found for UID 404/);
82
+ });
83
+ it("refuses when credentials are missing", async () => {
84
+ loadCredentials.mockResolvedValue({ error: "Email not configured." });
85
+ const out = await emailFetchAttachment({ accountId: "acc", uid: 99 });
86
+ expect(out).toMatch(/Email not configured/);
87
+ });
88
+ });
89
+ //# sourceMappingURL=email-fetch-attachment.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email-fetch-attachment.test.js","sourceRoot":"","sources":["../../src/__tests__/email-fetch-attachment.test.ts"],"names":[],"mappings":"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"}
@@ -9,6 +9,7 @@ import { z } from "zod";
9
9
  import { emailSetup } from "./tools/email-setup.js";
10
10
  import { emailRead } from "./tools/email-read.js";
11
11
  import { emailFetchBody } from "./tools/email-fetch-body.js";
12
+ import { emailFetchAttachment } from "./tools/email-fetch-attachment.js";
12
13
  import { emailSend } from "./tools/email-send.js";
13
14
  import { emailDraft } from "./tools/email-draft.js";
14
15
  import { emailDraftEdit } from "./tools/email-draft-edit.js";
@@ -207,6 +208,33 @@ eagerTool(server, "email-fetch-body", "Retrieve the COMPLETE decoded body of one
207
208
  }
208
209
  });
209
210
  // ===================================================================
211
+ // email-fetch-attachment — extract the text of one attachment
212
+ // ===================================================================
213
+ eagerTool(server, "email-fetch-attachment", "Return the extracted TEXT of one attachment on an inbox message, addressed by uid plus an optional index (1-based, matching the Attachments (N): footer order) or filename. PDF is read via pdftotext, text/* by decode; other types report that they cannot be extracted, and a scanned PDF reports its missing text layer. This is the read-side twin of email-fetch-body (which returns the message body). Use it when actionable data (a meeting date, an amount) may sit inside a PDF rather than the email text.", {
214
+ ...MAILBOX_ARG,
215
+ uid: z.coerce.number().int().describe("IMAP UID of the inbox message (from email-read/email-search/email-fetch output)."),
216
+ index: z.coerce.number().int().positive().optional().describe("1-based attachment number from the Attachments (N): footer. Omit when the message has a single attachment."),
217
+ filename: z.string().optional().describe("Attachment filename to read. Omit when the message has a single attachment; ignored when index is given."),
218
+ }, async (params) => {
219
+ if (!accountId)
220
+ return refuseNoAccount("email-fetch-attachment");
221
+ try {
222
+ const text = await emailFetchAttachment({ ...params, accountId });
223
+ return { content: [{ type: "text", text }] };
224
+ }
225
+ catch (err) {
226
+ return {
227
+ content: [
228
+ {
229
+ type: "text",
230
+ text: `Failed to fetch attachment: ${err instanceof Error ? err.message : String(err)}`,
231
+ },
232
+ ],
233
+ isError: true,
234
+ };
235
+ }
236
+ });
237
+ // ===================================================================
210
238
  // email-send — send an email via SMTP
211
239
  // ===================================================================
212
240
  eagerTool(server, "email-send", "Send an email from the agent's email account via SMTP.", {