jevway 0.2.0 → 0.3.0

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 (41) hide show
  1. package/README.md +37 -2
  2. package/{INSTALL-FOR-AGENTS.md → SETUP.md} +94 -14
  3. package/dist/browser/downloads.js +103 -0
  4. package/dist/browser/frames.js +21 -1
  5. package/dist/browser/snapshot.js +53 -0
  6. package/dist/browser/tab.js +59 -10
  7. package/dist/cli/dispatch.js +2 -0
  8. package/dist/cli/inbox-command.js +199 -0
  9. package/dist/cli/look-command.js +10 -3
  10. package/dist/cli/run-command.js +51 -3
  11. package/dist/daemon/server.js +22 -3
  12. package/dist/inbox/inbox-file.js +43 -0
  13. package/dist/inbox/inbox-registry.js +33 -0
  14. package/dist/inbox/inbox.js +127 -0
  15. package/dist/inbox/mail-api.js +52 -0
  16. package/dist/inbox/message.js +34 -0
  17. package/dist/jev/secrets.js +4 -3
  18. package/dist/mcp/inbox-tools.js +82 -0
  19. package/dist/mcp/instructions.js +11 -2
  20. package/dist/mcp/server.js +15 -8
  21. package/dist/mcp/task-tools.js +24 -8
  22. package/dist/notify/desktop.js +40 -0
  23. package/dist/notify/person-messages.js +50 -0
  24. package/dist/output/layout.js +37 -1
  25. package/dist/output/manifest.js +6 -0
  26. package/dist/output/run-folder.js +44 -2
  27. package/dist/recording/describe-step.js +3 -0
  28. package/dist/session/session-service.js +31 -11
  29. package/dist/setup/skill-command.js +44 -21
  30. package/dist/task/ask-user.js +182 -0
  31. package/dist/task/downloads.js +63 -0
  32. package/dist/task/person-wait.js +104 -0
  33. package/dist/task/present.js +32 -11
  34. package/dist/task/run-task.js +265 -28
  35. package/dist/task/start-task.js +49 -6
  36. package/dist/task/totp.js +124 -0
  37. package/dist/task/upload-files.js +237 -5
  38. package/package.json +2 -2
  39. package/skill/jevway/SKILL.md +5 -2
  40. package/skill/jevway-inbox/SKILL.md +61 -0
  41. package/skill/jevway-inbox/scripts/inbox.mjs +209 -0
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * inbox.mjs: a throwaway email inbox on the mail.tm REST API (no key, no dependencies, Node >= 24).
4
+ *
5
+ * Gives a coding agent a real address for a test or demo sign-up the user asked for, then pulls the verification
6
+ * code or confirmation link out of the email that arrives. The password lives only in the chmod-600 env file and is
7
+ * never printed.
8
+ *
9
+ * Usage:
10
+ * node inbox.mjs create --out "$SCRATCH/inbox.env" # prints only the address
11
+ * node inbox.mjs wait --env "$SCRATCH/inbox.env" --from example --timeout 120
12
+ * # → {"id":"…","from":"…","subject":"…","code":"123456","links":[…],"text":"…"}
13
+ * node inbox.mjs list --env "$SCRATCH/inbox.env" # date, from, subject, id
14
+ * node inbox.mjs read --env "$SCRATCH/inbox.env" <id> # full message text
15
+ *
16
+ * Env file (written by `create`, the same lines jevway's own inbox files hold): ADDR=… PASS=… API=…
17
+ * `create` tries mail.tm, then mail.gw (same API) once if mail.tm fails. `--api https://api.mail.gw` (or
18
+ * MAILTM_API) uses only that provider: use it once when a site rejects the first address's domain.
19
+ */
20
+
21
+ import { randomBytes } from 'node:crypto'
22
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
23
+ import { tmpdir } from 'node:os'
24
+ import { dirname, join } from 'node:path'
25
+ import { parseArgs } from 'node:util'
26
+
27
+ const MAIL_TM = 'https://api.mail.tm'
28
+ const MAIL_GW = 'https://api.mail.gw'
29
+ const POLL_INTERVAL_MS = 3000
30
+
31
+ // --- HTTP ---
32
+
33
+ /** Replaces every secret in `text` with `[secret]`, so a provider that echoes one never leaks it. */
34
+ const scrub = (text, secrets) => secrets.filter(Boolean).reduce((clean, secret) => clean.split(secret).join('[secret]'), text)
35
+
36
+ async function callApi(api, path, { method = 'GET', body, token, secrets = [] } = {}) {
37
+ const response = await fetch(`${api}${path}`, {
38
+ method,
39
+ headers: {
40
+ accept: 'application/ld+json',
41
+ ...(body && { 'content-type': 'application/json' }),
42
+ ...(token && { authorization: `Bearer ${token}` }),
43
+ },
44
+ body: body && JSON.stringify(body),
45
+ })
46
+ if (!response.ok) {
47
+ const detail = scrub((await response.text()).slice(0, 300), [...secrets, token])
48
+ throw new Error(`${method} ${api}${path} → ${response.status} ${detail}`)
49
+ }
50
+ return response.json()
51
+ }
52
+
53
+ /** Collections come back as `{ 'hydra:member': [...] }` (ld+json) or a bare array (plain json). */
54
+ const membersOf = (collection) => (Array.isArray(collection) ? collection : collection['hydra:member'] ?? [])
55
+
56
+ const fetchToken = async ({ ADDR, PASS, API }) =>
57
+ (await callApi(API, '/token', { method: 'POST', body: { address: ADDR, password: PASS }, secrets: [PASS] })).token
58
+
59
+ const listMessages = async (inbox, token) => membersOf(await callApi(inbox.API, '/messages', { token, secrets: [inbox.PASS] }))
60
+
61
+ const readMessage = (inbox, token, id) => callApi(inbox.API, `/messages/${encodeURIComponent(id)}`, { token, secrets: [inbox.PASS] })
62
+
63
+ // --- Env file ---
64
+
65
+ function readEnvFile(file) {
66
+ if (!file) throw new Error('missing --env <file> (written by `create`)')
67
+ const entries = readFileSync(file, 'utf8')
68
+ .split('\n')
69
+ .map((line) => line.match(/^(\w+)=(.*)$/))
70
+ .filter(Boolean)
71
+ .map(([, key, value]) => [key, value.trim()])
72
+ const inbox = Object.fromEntries(entries)
73
+ if (!inbox.ADDR || !inbox.PASS) throw new Error(`${file} has no ADDR/PASS`)
74
+ return { API: process.env.MAILTM_API || MAIL_TM, ...inbox }
75
+ }
76
+
77
+ function writeEnvFile(file, inbox) {
78
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
79
+ writeFileSync(file, `ADDR=${inbox.ADDR}\nPASS=${inbox.PASS}\nAPI=${inbox.API}\n`, { mode: 0o600 })
80
+ chmodSync(file, 0o600) // mode is ignored when the file already exists
81
+ }
82
+
83
+ // --- Message parsing ---
84
+
85
+ const htmlOf = (message) => [message.html ?? []].flat().join('\n')
86
+
87
+ const stripHtml = (html) =>
88
+ html.replace(/<(style|script)[\s\S]*?<\/\1>/gi, '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
89
+
90
+ const bodyText = (message) => message.text || stripHtml(htmlOf(message))
91
+
92
+ /** First standalone 4–8 digit number in subject + body ("Your code is 482913" → "482913"). */
93
+ const extractCode = (message) =>
94
+ `${message.subject}\n${bodyText(message)}`.match(/(?<![\w.,/-])\d{4,8}(?!\d|[.,/-]\d)/)?.[0] ?? null
95
+
96
+ // Doctype/namespace URLs and images (logos, tracking pixels) are never the link to follow.
97
+ const NOT_A_LINK = /^https?:\/\/www\.w3\.org\/|\.(png|jpe?g|gif|svg|webp|ico)(\?|$)/i
98
+
99
+ /** Unique http(s) URLs a person could follow, from the text and html parts, html entities for `&` decoded. */
100
+ function extractLinks(message) {
101
+ const urls = `${message.text ?? ''}\n${htmlOf(message)}`.match(/https?:\/\/[^\s"'<>)\]]+/g) ?? []
102
+ return [...new Set(urls.map((url) => url.replace(/&amp;/g, '&')))].filter((url) => !NOT_A_LINK.test(url))
103
+ }
104
+
105
+ const matchesFilters = (message, { from, subject }) => {
106
+ const sender = `${message.from?.name ?? ''} ${message.from?.address ?? ''}`.toLowerCase()
107
+ return (
108
+ (!from || sender.includes(from.toLowerCase())) &&
109
+ (!subject || (message.subject ?? '').toLowerCase().includes(subject.toLowerCase()))
110
+ )
111
+ }
112
+
113
+ // --- Commands ---
114
+
115
+ async function createOn(api) {
116
+ const domains = membersOf(await callApi(api, '/domains'))
117
+ const domain = domains.find((entry) => entry.isActive !== false)?.domain
118
+ if (!domain) throw new Error(`${api} offered no active domain`)
119
+ const inbox = {
120
+ ADDR: `test-${randomBytes(5).toString('hex')}@${domain}`,
121
+ PASS: randomBytes(18).toString('base64url'),
122
+ API: api,
123
+ }
124
+ await callApi(api, '/accounts', { method: 'POST', body: { address: inbox.ADDR, password: inbox.PASS }, secrets: [inbox.PASS] })
125
+ return inbox
126
+ }
127
+
128
+ async function create({ out, api }) {
129
+ let inbox
130
+ if (api) {
131
+ inbox = await createOn(api)
132
+ } else {
133
+ try {
134
+ inbox = await createOn(MAIL_TM)
135
+ } catch (first) {
136
+ console.error(`mail.tm failed (${first.message}); trying mail.gw once`)
137
+ inbox = await createOn(MAIL_GW)
138
+ }
139
+ }
140
+ const envFile = out || join(tmpdir(), 'jevway-inbox', `${inbox.ADDR.split('@')[0]}.env`)
141
+ writeEnvFile(envFile, inbox)
142
+ console.error(`credentials → ${envFile} (chmod 600)`)
143
+ console.log(inbox.ADDR)
144
+ }
145
+
146
+ async function wait({ env, from, subject, timeout }) {
147
+ const inbox = readEnvFile(env)
148
+ const token = await fetchToken(inbox)
149
+ const deadline = Date.now() + Number(timeout) * 1000
150
+
151
+ for (;;) {
152
+ const match = (await listMessages(inbox, token)).find((message) => matchesFilters(message, { from, subject }))
153
+ if (match) {
154
+ const message = await readMessage(inbox, token, match.id)
155
+ const text = bodyText(message)
156
+ const result = { id: message.id, from: message.from?.address, subject: message.subject, code: extractCode(message), links: extractLinks(message), text }
157
+ console.log(JSON.stringify(result, null, 2))
158
+ return
159
+ }
160
+ const remaining = deadline - Date.now()
161
+ if (remaining <= 0) break
162
+ await new Promise((resolve) => setTimeout(resolve, Math.min(POLL_INTERVAL_MS, remaining)))
163
+ }
164
+ throw new Error(`no matching message for ${inbox.ADDR} within ${timeout}s`)
165
+ }
166
+
167
+ async function list({ env }) {
168
+ const inbox = readEnvFile(env)
169
+ const messages = await listMessages(inbox, await fetchToken(inbox))
170
+ if (messages.length === 0) return console.log(`(empty) ${inbox.ADDR}`)
171
+ for (const message of messages) {
172
+ console.log([message.createdAt, message.from?.address, message.subject, message.id].join(' '))
173
+ }
174
+ }
175
+
176
+ async function read({ env }, id) {
177
+ if (!id) throw new Error('usage: read --env <file> <id>')
178
+ const inbox = readEnvFile(env)
179
+ const message = await readMessage(inbox, await fetchToken(inbox), id)
180
+ console.log(`From: ${message.from?.address}\nSubject: ${message.subject}\nDate: ${message.createdAt}\n\n${bodyText(message)}`)
181
+ }
182
+
183
+ // --- CLI ---
184
+
185
+ const commands = { create, wait, list, read }
186
+
187
+ const { values: options, positionals } = parseArgs({
188
+ allowPositionals: true,
189
+ options: {
190
+ out: { type: 'string' },
191
+ env: { type: 'string' },
192
+ api: { type: 'string', default: process.env.MAILTM_API },
193
+ from: { type: 'string' },
194
+ subject: { type: 'string' },
195
+ timeout: { type: 'string', default: '120' },
196
+ },
197
+ })
198
+
199
+ const [commandName, ...commandArgs] = positionals
200
+ const command = commands[commandName]
201
+ if (!command) {
202
+ console.error('usage: inbox.mjs <create [--out f] [--api url] | wait --env f [--from s] [--subject s] [--timeout 120] | list --env f | read --env f <id>>')
203
+ process.exit(2)
204
+ }
205
+
206
+ command({ ...options, api: options.api?.replace(/\/$/, '') }, ...commandArgs).catch((error) => {
207
+ console.error(`inbox.mjs ${commandName}: ${error.message}`)
208
+ process.exit(1)
209
+ })