sanook-cli 0.5.1 → 0.5.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.
Files changed (144) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +57 -8
  3. package/README.md +240 -23
  4. package/README.th.md +87 -6
  5. package/dist/approval.js +6 -0
  6. package/dist/bin.js +3026 -196
  7. package/dist/brain-context.js +223 -0
  8. package/dist/brain-doctor.js +318 -0
  9. package/dist/brain-eval.js +186 -0
  10. package/dist/brain-final.js +371 -0
  11. package/dist/brain-review.js +382 -0
  12. package/dist/brain.js +12 -1
  13. package/dist/brand.js +1 -1
  14. package/dist/cli-args.js +152 -0
  15. package/dist/cli-option-values.js +16 -0
  16. package/dist/commands.js +172 -13
  17. package/dist/compaction.js +96 -11
  18. package/dist/config.js +118 -28
  19. package/dist/context-compression.js +191 -0
  20. package/dist/cost.js +49 -15
  21. package/dist/first-run.js +21 -0
  22. package/dist/gateway/auth.js +37 -8
  23. package/dist/gateway/bluebubbles.js +205 -0
  24. package/dist/gateway/config.js +929 -0
  25. package/dist/gateway/deliver.js +357 -0
  26. package/dist/gateway/discord.js +124 -0
  27. package/dist/gateway/email.js +472 -0
  28. package/dist/gateway/googlechat.js +207 -0
  29. package/dist/gateway/homeassistant.js +256 -0
  30. package/dist/gateway/ledger.js +18 -0
  31. package/dist/gateway/line.js +171 -0
  32. package/dist/gateway/lock.js +3 -1
  33. package/dist/gateway/matrix.js +366 -0
  34. package/dist/gateway/mattermost.js +322 -0
  35. package/dist/gateway/ntfy.js +218 -0
  36. package/dist/gateway/schedule.js +31 -4
  37. package/dist/gateway/serve.js +267 -7
  38. package/dist/gateway/server.js +253 -19
  39. package/dist/gateway/service.js +224 -0
  40. package/dist/gateway/session.js +343 -0
  41. package/dist/gateway/signal.js +351 -0
  42. package/dist/gateway/slack.js +124 -0
  43. package/dist/gateway/sms.js +169 -0
  44. package/dist/gateway/targets.js +576 -0
  45. package/dist/gateway/teams.js +106 -0
  46. package/dist/gateway/telegram.js +38 -15
  47. package/dist/gateway/webhooks.js +220 -0
  48. package/dist/gateway/whatsapp.js +230 -0
  49. package/dist/hooks.js +13 -2
  50. package/dist/insights-args.js +35 -0
  51. package/dist/insights.js +86 -0
  52. package/dist/loop.js +123 -24
  53. package/dist/lsp/index.js +23 -5
  54. package/dist/mcp-registry.js +350 -0
  55. package/dist/mcp-server.js +1 -1
  56. package/dist/mcp.js +44 -6
  57. package/dist/memory.js +100 -33
  58. package/dist/orchestrate.js +49 -19
  59. package/dist/personality.js +58 -0
  60. package/dist/providers/codex.js +70 -36
  61. package/dist/providers/keys.js +1 -1
  62. package/dist/providers/models.js +1 -1
  63. package/dist/providers/registry.js +14 -47
  64. package/dist/search/chunk.js +7 -8
  65. package/dist/search/cli.js +75 -0
  66. package/dist/search/embed-store.js +3 -0
  67. package/dist/search/indexer.js +44 -1
  68. package/dist/search/store.js +23 -1
  69. package/dist/session.js +93 -7
  70. package/dist/skill-install.js +29 -12
  71. package/dist/support-dump.js +175 -0
  72. package/dist/tools/edit.js +45 -15
  73. package/dist/tools/git.js +10 -5
  74. package/dist/tools/homeassistant.js +106 -0
  75. package/dist/tools/index.js +5 -0
  76. package/dist/tools/list.js +19 -6
  77. package/dist/tools/permission.js +923 -9
  78. package/dist/tools/read.js +16 -4
  79. package/dist/tools/schedule.js +19 -3
  80. package/dist/tools/search.js +217 -13
  81. package/dist/tools/task.js +18 -7
  82. package/dist/tools/timeout.js +21 -3
  83. package/dist/trust.js +11 -1
  84. package/dist/ui/app.js +48 -8
  85. package/dist/ui/history.js +37 -5
  86. package/dist/ui/mentions.js +3 -2
  87. package/dist/ui/setup.js +17 -4
  88. package/dist/update.js +24 -11
  89. package/dist/worktree.js +175 -4
  90. package/package.json +4 -4
  91. package/second-brain/AGENTS.md +6 -4
  92. package/second-brain/CLAUDE.md +7 -1
  93. package/second-brain/Evals/_Index.md +10 -2
  94. package/second-brain/Evals/quality-ledger.md +9 -1
  95. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  96. package/second-brain/GEMINI.md +5 -4
  97. package/second-brain/Home.md +1 -1
  98. package/second-brain/Projects/_Index.md +3 -1
  99. package/second-brain/Projects/sanook-cli/_Index.md +26 -0
  100. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +156 -0
  101. package/second-brain/README.md +1 -1
  102. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  103. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  104. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  105. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  106. package/second-brain/Research/_Index.md +6 -1
  107. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  108. package/second-brain/Reviews/_Index.md +1 -1
  109. package/second-brain/Runbooks/_Index.md +6 -1
  110. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  111. package/second-brain/SANOOK.md +45 -0
  112. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  113. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  114. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  115. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  116. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  117. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  118. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  119. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  120. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  121. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  122. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  123. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  124. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  125. package/second-brain/Sessions/_Index.md +15 -1
  126. package/second-brain/Shared/AI-Context-Index.md +22 -0
  127. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  128. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  129. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  130. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  131. package/second-brain/Shared/Operating-State/current-state.md +22 -3
  132. package/second-brain/Shared/Scripts/_Index.md +3 -1
  133. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  134. package/second-brain/Shared/Tech-Standards/_Index.md +4 -1
  135. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  136. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  137. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  138. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  139. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  140. package/second-brain/Templates/_Index.md +9 -0
  141. package/second-brain/Templates/final-lite.md +111 -0
  142. package/second-brain/Templates/final.md +231 -0
  143. package/second-brain/Vault Structure Map.md +2 -1
  144. package/skills/structured-output-llm/SKILL.md +1 -1
@@ -0,0 +1,472 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import net from 'node:net';
3
+ import tls from 'node:tls';
4
+ import { hostname } from 'node:os';
5
+ import { redactKey } from '../providers/keys.js';
6
+ import { runGatewayAgent } from './session.js';
7
+ export function normalizeEmailAddress(address) {
8
+ const trimmed = address.trim();
9
+ if (!/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(trimmed)) {
10
+ throw new Error(`email address ไม่ถูกต้อง: ${address}`);
11
+ }
12
+ return trimmed;
13
+ }
14
+ function encodeHeader(raw) {
15
+ const clean = raw.replace(/[\r\n]+/g, ' ').trim();
16
+ return /^[\x20-\x7e]*$/.test(clean) ? clean : `=?UTF-8?B?${Buffer.from(clean, 'utf8').toString('base64')}?=`;
17
+ }
18
+ function dotStuff(body) {
19
+ return body.replace(/\r?\n/g, '\r\n').replace(/^\./gm, '..');
20
+ }
21
+ function messageDomain(address) {
22
+ return address.split('@')[1] || hostname() || 'localhost';
23
+ }
24
+ export function buildEmailMessage(config, to, text, subject = 'Sanook') {
25
+ return buildEmailMessageWithHeaders(config, to, text, { subject });
26
+ }
27
+ export function buildEmailMessageWithHeaders(config, to, text, options = {}) {
28
+ const from = normalizeEmailAddress(config.address);
29
+ const recipient = normalizeEmailAddress(to);
30
+ const messageId = `<${randomUUID()}@${messageDomain(from)}>`;
31
+ const fromHeader = config.fromName ? `${encodeHeader(config.fromName)} <${from}>` : from;
32
+ const refs = [options.references, options.inReplyTo].filter(Boolean).join(' ').trim();
33
+ const headers = [
34
+ `From: ${fromHeader}`,
35
+ `To: ${recipient}`,
36
+ `Subject: ${encodeHeader(options.subject ?? 'Sanook')}`,
37
+ `Date: ${new Date().toUTCString()}`,
38
+ `Message-ID: ${messageId}`,
39
+ ...(options.inReplyTo ? [`In-Reply-To: ${options.inReplyTo}`] : []),
40
+ ...(refs ? [`References: ${refs}`] : []),
41
+ 'MIME-Version: 1.0',
42
+ 'Content-Type: text/plain; charset=UTF-8',
43
+ 'Content-Transfer-Encoding: 8bit',
44
+ ];
45
+ return {
46
+ messageId,
47
+ raw: `${headers.join('\r\n')}\r\n\r\n${dotStuff(text)}\r\n`,
48
+ };
49
+ }
50
+ function connectSocket(host, port, secure) {
51
+ return new Promise((resolve, reject) => {
52
+ const socket = secure
53
+ ? tls.connect({ host, port, servername: host }, () => resolve(socket))
54
+ : net.connect({ host, port }, () => resolve(socket));
55
+ socket.once('error', reject);
56
+ });
57
+ }
58
+ function readResponse(socket, timeoutMs = 20_000) {
59
+ return new Promise((resolve, reject) => {
60
+ let buffer = '';
61
+ const timer = setTimeout(() => cleanup(new Error('SMTP timeout')), timeoutMs);
62
+ const onData = (chunk) => {
63
+ buffer += chunk.toString();
64
+ const lines = buffer.split(/\r?\n/).filter(Boolean);
65
+ const last = lines[lines.length - 1];
66
+ const done = last?.match(/^(\d{3}) /);
67
+ if (done)
68
+ cleanup(undefined, { code: Number(done[1]), text: lines.join('\n') });
69
+ };
70
+ const onError = (e) => cleanup(e);
71
+ const cleanup = (err, value) => {
72
+ clearTimeout(timer);
73
+ socket.off('data', onData);
74
+ socket.off('error', onError);
75
+ if (err)
76
+ reject(err);
77
+ else
78
+ resolve(value);
79
+ };
80
+ socket.on('data', onData);
81
+ socket.once('error', onError);
82
+ });
83
+ }
84
+ async function command(socket, line, ok) {
85
+ socket.write(`${line}\r\n`);
86
+ const response = await readResponse(socket);
87
+ const accepted = Array.isArray(ok) ? ok : [ok];
88
+ if (!accepted.includes(response.code))
89
+ throw new Error(`SMTP ${line.split(/\s+/)[0]} failed: ${response.text}`);
90
+ return response;
91
+ }
92
+ async function wrapStartTls(socket, host) {
93
+ return new Promise((resolve, reject) => {
94
+ const secured = tls.connect({ socket: socket, servername: host }, () => resolve(secured));
95
+ secured.once('error', reject);
96
+ });
97
+ }
98
+ async function sendViaSmtp(email) {
99
+ const port = email.config.smtpPort ?? 587;
100
+ const implicitTls = port === 465;
101
+ let socket = await connectSocket(email.config.smtpHost, port, implicitTls);
102
+ try {
103
+ const greeting = await readResponse(socket);
104
+ if (greeting.code !== 220)
105
+ throw new Error(`SMTP greeting failed: ${greeting.text}`);
106
+ let ehlo = await command(socket, `EHLO ${hostname() || 'localhost'}`, 250);
107
+ if (!implicitTls && /STARTTLS/im.test(ehlo.text)) {
108
+ await command(socket, 'STARTTLS', 220);
109
+ socket = await wrapStartTls(socket, email.config.smtpHost);
110
+ ehlo = await command(socket, `EHLO ${hostname() || 'localhost'}`, 250);
111
+ }
112
+ if (!implicitTls && port !== 25 && !/AUTH/im.test(ehlo.text)) {
113
+ throw new Error('SMTP server did not advertise AUTH after EHLO');
114
+ }
115
+ const auth = Buffer.from(`\0${email.config.address}\0${email.config.password}`, 'utf8').toString('base64');
116
+ await command(socket, `AUTH PLAIN ${auth}`, 235);
117
+ await command(socket, `MAIL FROM:<${normalizeEmailAddress(email.config.address)}>`, 250);
118
+ await command(socket, `RCPT TO:<${normalizeEmailAddress(email.to)}>`, [250, 251]);
119
+ await command(socket, 'DATA', 354);
120
+ socket.write(`${email.raw}\r\n.\r\n`);
121
+ const sent = await readResponse(socket);
122
+ if (sent.code !== 250)
123
+ throw new Error(`SMTP DATA failed: ${sent.text}`);
124
+ await command(socket, 'QUIT', 221).catch(() => { });
125
+ }
126
+ finally {
127
+ socket.end();
128
+ }
129
+ }
130
+ export async function sendEmailMessage(config, to, text, options = {}) {
131
+ if (!config.address || !config.password || !config.smtpHost) {
132
+ throw new Error('Email ต้องมี address, password และ smtpHost');
133
+ }
134
+ const recipient = normalizeEmailAddress(to);
135
+ const { raw, messageId } = buildEmailMessageWithHeaders(config, recipient, text, options);
136
+ await (options.transport ?? sendViaSmtp)({ config, to: recipient, raw });
137
+ return { to: recipient, messageId };
138
+ }
139
+ function headerValue(raw, name) {
140
+ const lines = raw.replace(/\r?\n[ \t]+/g, ' ').split(/\r?\n/);
141
+ const prefix = `${name.toLowerCase()}:`;
142
+ const found = lines.find((line) => line.toLowerCase().startsWith(prefix));
143
+ return found?.slice(prefix.length).trim();
144
+ }
145
+ function splitHeaderBody(raw) {
146
+ const match = raw.match(/\r?\n\r?\n/);
147
+ if (!match || match.index == null)
148
+ return { headers: raw, body: '' };
149
+ return { headers: raw.slice(0, match.index), body: raw.slice(match.index + match[0].length) };
150
+ }
151
+ function decodeQuotedPrintableHeader(raw) {
152
+ const bytes = [];
153
+ for (let i = 0; i < raw.length; i += 1) {
154
+ const char = raw[i];
155
+ if (char === '_') {
156
+ bytes.push(0x20);
157
+ continue;
158
+ }
159
+ if (char === '=' && /^[0-9A-Fa-f]{2}$/.test(raw.slice(i + 1, i + 3))) {
160
+ bytes.push(Number.parseInt(raw.slice(i + 1, i + 3), 16));
161
+ i += 2;
162
+ continue;
163
+ }
164
+ bytes.push(...Buffer.from(char, 'utf8'));
165
+ }
166
+ return Buffer.from(bytes).toString('utf8');
167
+ }
168
+ function decodeHeader(raw) {
169
+ if (!raw)
170
+ return '';
171
+ return raw.replace(/=\?utf-8\?([bq])\?([^?]+)\?=/gi, (_m, encoding, value) => encoding.toLowerCase() === 'b' ? Buffer.from(value, 'base64').toString('utf8') : decodeQuotedPrintableHeader(value));
172
+ }
173
+ export function extractEmailAddress(raw) {
174
+ const match = raw.match(/<([^<>@\s]+@[^<>@\s]+)>/);
175
+ return (match?.[1] ?? raw).replace(/^mailto:/i, '').trim().toLowerCase();
176
+ }
177
+ function stripHtml(raw) {
178
+ return raw
179
+ .replace(/<style[\s\S]*?<\/style>/gi, '')
180
+ .replace(/<script[\s\S]*?<\/script>/gi, '')
181
+ .replace(/<br\s*\/?>/gi, '\n')
182
+ .replace(/<\/p>/gi, '\n')
183
+ .replace(/<[^>]+>/g, '')
184
+ .replace(/&nbsp;/g, ' ')
185
+ .replace(/&lt;/g, '<')
186
+ .replace(/&gt;/g, '>')
187
+ .replace(/&amp;/g, '&');
188
+ }
189
+ function decodeTransfer(raw, encoding) {
190
+ if (/base64/i.test(encoding ?? ''))
191
+ return Buffer.from(raw.replace(/\s+/g, ''), 'base64').toString('utf8');
192
+ if (/quoted-printable/i.test(encoding ?? '')) {
193
+ return raw
194
+ .replace(/=\r?\n/g, '')
195
+ .replace(/=([0-9A-F]{2})/gi, (_m, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
196
+ }
197
+ return raw;
198
+ }
199
+ function messageBody(raw) {
200
+ const { headers, body } = splitHeaderBody(raw);
201
+ if (!body)
202
+ return '';
203
+ const contentType = headerValue(headers, 'Content-Type') ?? '';
204
+ const encoding = headerValue(headers, 'Content-Transfer-Encoding');
205
+ const boundary = contentType.match(/boundary="?([^";]+)"?/i)?.[1];
206
+ if (boundary) {
207
+ const parts = body.split(`--${boundary}`);
208
+ const textPart = parts.find((part) => /content-type:\s*text\/plain/i.test(part));
209
+ const htmlPart = parts.find((part) => /content-type:\s*text\/html/i.test(part));
210
+ const chosen = textPart ?? htmlPart ?? '';
211
+ const { headers: partHeaders, body: partBody } = splitHeaderBody(chosen);
212
+ if (partBody) {
213
+ const decoded = decodeTransfer(partBody, headerValue(partHeaders, 'Content-Transfer-Encoding'));
214
+ return /content-type:\s*text\/html/i.test(partHeaders) ? stripHtml(decoded).trim() : decoded.trim();
215
+ }
216
+ }
217
+ const decoded = decodeTransfer(body, encoding);
218
+ return /text\/html/i.test(contentType) ? stripHtml(decoded).trim() : decoded.trim();
219
+ }
220
+ export function parseRawEmail(uid, raw) {
221
+ const { headers } = splitHeaderBody(raw);
222
+ return {
223
+ uid,
224
+ from: extractEmailAddress(headerValue(headers, 'From') ?? ''),
225
+ subject: decodeHeader(headerValue(headers, 'Subject')) || '(no subject)',
226
+ text: messageBody(raw),
227
+ messageId: headerValue(headers, 'Message-ID'),
228
+ references: headerValue(headers, 'References'),
229
+ autoSubmitted: headerValue(headers, 'Auto-Submitted'),
230
+ precedence: headerValue(headers, 'Precedence'),
231
+ listUnsubscribe: headerValue(headers, 'List-Unsubscribe'),
232
+ };
233
+ }
234
+ export function shouldProcessEmail(email, config) {
235
+ const from = extractEmailAddress(email.from);
236
+ const self = extractEmailAddress(config.address);
237
+ if (!from || from === self)
238
+ return false;
239
+ if (/^(no-?reply|mailer-daemon|postmaster|bounce)@/i.test(from))
240
+ return false;
241
+ if (email.autoSubmitted && !/^no$/i.test(email.autoSubmitted))
242
+ return false;
243
+ if (/bulk|junk|list/i.test(email.precedence ?? ''))
244
+ return false;
245
+ if (email.listUnsubscribe)
246
+ return false;
247
+ if (config.allowAllUsers)
248
+ return true;
249
+ const allowed = new Set((config.allowedUsers ?? []).map((s) => s.toLowerCase()));
250
+ return allowed.has(from);
251
+ }
252
+ function imapQuote(raw) {
253
+ return `"${raw.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
254
+ }
255
+ function escapeRegExp(raw) {
256
+ return raw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
257
+ }
258
+ function readImapGreeting(socket, timeoutMs = 30_000) {
259
+ return new Promise((resolve, reject) => {
260
+ let buffer = '';
261
+ const timer = setTimeout(() => cleanup(new Error('IMAP greeting timeout')), timeoutMs);
262
+ const onData = (chunk) => {
263
+ buffer += chunk.toString('utf8');
264
+ if (/^\* (OK|PREAUTH|BYE|BAD)/m.test(buffer))
265
+ cleanup(undefined, buffer);
266
+ };
267
+ const onError = (e) => cleanup(e);
268
+ const cleanup = (err, value) => {
269
+ clearTimeout(timer);
270
+ socket.off('data', onData);
271
+ socket.off('error', onError);
272
+ if (err)
273
+ reject(err);
274
+ else
275
+ resolve(value ?? buffer);
276
+ };
277
+ socket.on('data', onData);
278
+ socket.once('error', onError);
279
+ });
280
+ }
281
+ function readImapResponse(socket, tag, timeoutMs = 30_000) {
282
+ return new Promise((resolve, reject) => {
283
+ let buffer = '';
284
+ const tagPattern = escapeRegExp(tag);
285
+ const timer = setTimeout(() => cleanup(new Error('IMAP timeout')), timeoutMs);
286
+ const onData = (chunk) => {
287
+ buffer += chunk.toString('utf8');
288
+ if (new RegExp(`^${tagPattern} (OK|NO|BAD)`, 'm').test(buffer))
289
+ cleanup(undefined, buffer);
290
+ };
291
+ const onError = (e) => cleanup(e);
292
+ const cleanup = (err, value) => {
293
+ clearTimeout(timer);
294
+ socket.off('data', onData);
295
+ socket.off('error', onError);
296
+ if (err)
297
+ reject(err);
298
+ else
299
+ resolve(value ?? buffer);
300
+ };
301
+ socket.on('data', onData);
302
+ socket.once('error', onError);
303
+ });
304
+ }
305
+ async function imapCommand(socket, tag, commandText) {
306
+ socket.write(`${tag} ${commandText}\r\n`);
307
+ const response = await readImapResponse(socket, tag);
308
+ if (!new RegExp(`^${escapeRegExp(tag)} OK`, 'm').test(response))
309
+ throw new Error(`IMAP ${commandText.split(/\s+/)[0]} failed: ${response}`);
310
+ return response;
311
+ }
312
+ export function parseImapSearchResponse(response) {
313
+ const line = response.split(/\r?\n/).find((l) => l.startsWith('* SEARCH'));
314
+ if (!line)
315
+ return [];
316
+ return line
317
+ .slice('* SEARCH'.length)
318
+ .trim()
319
+ .split(/\s+/)
320
+ .filter(Boolean)
321
+ .map(Number)
322
+ .filter((n) => Number.isSafeInteger(n));
323
+ }
324
+ export function parseImapFetchResponse(response) {
325
+ const out = [];
326
+ const marker = /UID\s+(\d+)[\s\S]*?BODY(?:\.PEEK)?\[\]\s+\{(\d+)\}\r?\n/gim;
327
+ let match;
328
+ while ((match = marker.exec(response))) {
329
+ const uid = Number(match[1]);
330
+ const len = Number(match[2]);
331
+ const bodyStart = marker.lastIndex;
332
+ const raw = response.slice(bodyStart, bodyStart + len);
333
+ if (Number.isSafeInteger(uid) && raw)
334
+ out.push(parseRawEmail(uid, raw));
335
+ marker.lastIndex = bodyStart + len;
336
+ }
337
+ return out;
338
+ }
339
+ async function connectImap(config) {
340
+ const port = config.imapPort ?? 993;
341
+ return connectSocket(config.imapHost, port, port === 993);
342
+ }
343
+ export async function fetchUnseenEmails(config) {
344
+ if (!config.address || !config.password || !config.imapHost)
345
+ throw new Error('Email ต้องมี address, password และ imapHost');
346
+ const socket = await connectImap(config);
347
+ try {
348
+ const greeting = await readImapGreeting(socket).catch(() => '');
349
+ if (greeting && /^(\* )?BAD/im.test(greeting))
350
+ throw new Error(`IMAP greeting failed: ${greeting}`);
351
+ await imapCommand(socket, 'A1', `LOGIN ${imapQuote(config.address)} ${imapQuote(config.password)}`);
352
+ await imapCommand(socket, 'A2', 'SELECT INBOX');
353
+ const search = await imapCommand(socket, 'A3', 'UID SEARCH UNSEEN');
354
+ const uids = parseImapSearchResponse(search);
355
+ if (!uids.length)
356
+ return [];
357
+ const fetch = await imapCommand(socket, 'A4', `UID FETCH ${uids.join(',')} (UID BODY.PEEK[])`);
358
+ return parseImapFetchResponse(fetch);
359
+ }
360
+ finally {
361
+ await imapCommand(socket, 'ZZ', 'LOGOUT').catch(() => { });
362
+ socket.end();
363
+ }
364
+ }
365
+ export async function markEmailSeen(config, uid) {
366
+ if (!config.address || !config.password || !config.imapHost)
367
+ throw new Error('Email ต้องมี address, password และ imapHost');
368
+ const socket = await connectImap(config);
369
+ try {
370
+ await readImapGreeting(socket).catch(() => '');
371
+ await imapCommand(socket, 'A1', `LOGIN ${imapQuote(config.address)} ${imapQuote(config.password)}`);
372
+ await imapCommand(socket, 'A2', 'SELECT INBOX');
373
+ await imapCommand(socket, 'A3', `UID STORE ${uid} +FLAGS.SILENT (\\Seen)`);
374
+ }
375
+ finally {
376
+ await imapCommand(socket, 'ZZ', 'LOGOUT').catch(() => { });
377
+ socket.end();
378
+ }
379
+ }
380
+ function replySubject(subject) {
381
+ return /^re:/i.test(subject) ? subject : `Re: ${subject}`;
382
+ }
383
+ export async function sendEmailReply(config, email, text) {
384
+ await sendEmailMessage(config, email.from, text || '(ไม่มีผลลัพธ์)', {
385
+ subject: replySubject(email.subject),
386
+ inReplyTo: email.messageId,
387
+ references: email.references,
388
+ });
389
+ }
390
+ function emailPrompt(email) {
391
+ return [
392
+ `Email from: ${email.from}`,
393
+ `Subject: ${email.subject}`,
394
+ '',
395
+ 'Message:',
396
+ email.text || '(empty)',
397
+ ].join('\n');
398
+ }
399
+ export function startEmail(opts) {
400
+ if (!opts.address || !opts.password || !opts.imapHost || !opts.smtpHost) {
401
+ opts.onLog?.('Email ไม่เริ่ม: ต้องตั้ง address/password/imapHost/smtpHost');
402
+ return () => { };
403
+ }
404
+ if (!opts.allowAllUsers && !opts.allowedUsers?.length) {
405
+ opts.onLog?.('⛔ Email ไม่เริ่ม: ต้องตั้ง allowedUsers หรือ allowAllUsers — remote surface นี้รัน agent ได้');
406
+ return () => { };
407
+ }
408
+ const pollMs = Math.max(5, opts.pollIntervalSeconds ?? 15) * 1000;
409
+ const poller = opts.poller ?? fetchUnseenEmails;
410
+ const markSeen = opts.markSeen ?? markEmailSeen;
411
+ const sendReply = opts.sendReply ?? sendEmailReply;
412
+ const running = new Set();
413
+ let stopped = false;
414
+ let busy = false;
415
+ opts.onLog?.(`Email: IMAP polling เริ่มแล้ว (${opts.address}, ทุก ${pollMs / 1000}s)`);
416
+ async function handle(email) {
417
+ if (running.has(email.uid))
418
+ return;
419
+ running.add(email.uid);
420
+ try {
421
+ if (!shouldProcessEmail(email, opts)) {
422
+ opts.onLog?.(`Email: ข้าม ${email.uid} จาก ${email.from} (ไม่ผ่าน policy)`);
423
+ return;
424
+ }
425
+ opts.onLog?.(`Email ${email.uid} จาก ${email.from}: ${email.subject.slice(0, 60)}`);
426
+ try {
427
+ const result = await runGatewayAgent({
428
+ platform: 'email',
429
+ target: email.from,
430
+ model: opts.model,
431
+ prompt: emailPrompt(email),
432
+ userText: email.text,
433
+ budgetUsd: opts.budgetUsd,
434
+ permissionMode: opts.allowWrite === true ? 'auto' : 'ask',
435
+ });
436
+ if (!result.suppressDelivery)
437
+ await sendReply(opts, email, result.text || '(ไม่มีผลลัพธ์)');
438
+ }
439
+ catch (e) {
440
+ opts.onLog?.(`Email run error (${email.uid}): ${redactKey(e.message)}`);
441
+ await sendReply(opts, email, 'เกิดข้อผิดพลาดภายใน').catch((err) => opts.onLog?.(`Email reply error (${email.uid}): ${redactKey(err.message)}`));
442
+ }
443
+ }
444
+ finally {
445
+ await markSeen(opts, email.uid).catch((e) => opts.onLog?.(`Email mark-seen error (${email.uid}): ${redactKey(e.message)}`));
446
+ running.delete(email.uid);
447
+ }
448
+ }
449
+ async function tick() {
450
+ if (stopped || busy)
451
+ return;
452
+ busy = true;
453
+ try {
454
+ const emails = await poller(opts);
455
+ for (const email of emails)
456
+ await handle(email);
457
+ }
458
+ catch (e) {
459
+ if (!stopped)
460
+ opts.onLog?.(`Email poll error: ${redactKey(e.message)}`);
461
+ }
462
+ finally {
463
+ busy = false;
464
+ }
465
+ }
466
+ void tick();
467
+ const timer = setInterval(() => void tick(), pollMs);
468
+ return () => {
469
+ stopped = true;
470
+ clearInterval(timer);
471
+ };
472
+ }
@@ -0,0 +1,207 @@
1
+ import { createSign } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { redactKey } from '../providers/keys.js';
4
+ const GOOGLE_CHAT_API_BASE_URL = 'https://chat.googleapis.com';
5
+ const GOOGLE_OAUTH_TOKEN_URI = 'https://oauth2.googleapis.com/token';
6
+ const GOOGLE_CHAT_SCOPE = 'https://www.googleapis.com/auth/chat.bot';
7
+ const GOOGLE_CHAT_TEXT_LIMIT = 4_000;
8
+ function redactGoogleChatDetail(raw, secrets) {
9
+ let safe = redactKey(raw);
10
+ for (const secret of secrets) {
11
+ const value = secret?.trim();
12
+ if (value)
13
+ safe = safe.split(value).join('<secret>');
14
+ }
15
+ return safe;
16
+ }
17
+ function base64Url(input) {
18
+ return Buffer.from(input).toString('base64url');
19
+ }
20
+ export function normalizeGoogleChatApiBaseUrl(raw) {
21
+ const value = raw?.trim() || GOOGLE_CHAT_API_BASE_URL;
22
+ try {
23
+ const url = new URL(value);
24
+ if (url.protocol !== 'https:')
25
+ return undefined;
26
+ return url.toString().replace(/\/+$/, '');
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ export function normalizeGoogleChatWebhookUrl(raw) {
33
+ const value = raw?.trim();
34
+ if (!value)
35
+ return undefined;
36
+ try {
37
+ const url = new URL(value);
38
+ if (url.protocol !== 'https:')
39
+ return undefined;
40
+ return url.toString();
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ export function chunkGoogleChatText(raw, limit = GOOGLE_CHAT_TEXT_LIMIT) {
47
+ const text = raw.trim() || '(ไม่มีผลลัพธ์)';
48
+ if (text.length <= limit)
49
+ return [text];
50
+ const chunks = [];
51
+ for (let index = 0; index < text.length; index += limit) {
52
+ chunks.push(text.slice(index, index + limit));
53
+ }
54
+ return chunks;
55
+ }
56
+ export function parseGoogleChatTarget(config, explicitTarget) {
57
+ const target = explicitTarget?.trim() || config.homeChannel?.trim();
58
+ if (!target) {
59
+ const webhook = normalizeGoogleChatWebhookUrl(config.incomingWebhookUrl);
60
+ if (webhook)
61
+ return { type: 'webhook', value: webhook };
62
+ throw new Error('ต้องระบุ Google Chat space/webhook target หรือ home channel ใน gateway config');
63
+ }
64
+ if (/^https:\/\//i.test(target)) {
65
+ const webhookUrl = normalizeGoogleChatWebhookUrl(target);
66
+ if (!webhookUrl)
67
+ throw new Error('Google Chat incoming webhook target ต้องเป็น HTTPS URL ที่ถูกต้อง');
68
+ return { type: 'webhook', value: webhookUrl };
69
+ }
70
+ if (target.toLowerCase() === 'webhook') {
71
+ const webhookUrl = normalizeGoogleChatWebhookUrl(config.incomingWebhookUrl);
72
+ if (!webhookUrl)
73
+ throw new Error('ยังไม่ได้ตั้ง Google Chat incoming webhook URL');
74
+ return { type: 'webhook', value: webhookUrl };
75
+ }
76
+ const cleaned = target.replace(/^space[:/](?:spaces\/)?/i, 'spaces/').trim();
77
+ const match = /^(spaces\/[^/\s]+)(?:\/threads\/(.+))?$/.exec(cleaned);
78
+ if (!match)
79
+ throw new Error('Google Chat target ต้องเป็น spaces/<space-id> หรือ spaces/<space-id>/threads/<thread-id>');
80
+ const space = match[1];
81
+ const thread = match[2] ? `${space}/threads/${match[2]}` : undefined;
82
+ return { type: 'space', value: thread ?? space, space, thread };
83
+ }
84
+ export function googleChatApiUrl(config, path) {
85
+ const baseUrl = normalizeGoogleChatApiBaseUrl(config.apiBaseUrl);
86
+ if (!baseUrl)
87
+ throw new Error('Google Chat API base URL ต้องเป็น https:// URL');
88
+ return new URL(path, `${baseUrl}/`).toString();
89
+ }
90
+ export async function readGoogleServiceAccount(path) {
91
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
92
+ if (!parsed || typeof parsed !== 'object')
93
+ throw new Error('Google Chat service account JSON ไม่ถูกต้อง');
94
+ return parsed;
95
+ }
96
+ export function googleServiceAccountJwt(serviceAccount, now = Math.floor(Date.now() / 1000)) {
97
+ const issuer = serviceAccount.client_email?.trim();
98
+ const privateKey = serviceAccount.private_key;
99
+ const audience = serviceAccount.token_uri?.trim() || GOOGLE_OAUTH_TOKEN_URI;
100
+ if (!issuer || !privateKey)
101
+ throw new Error('Google Chat service account JSON ต้องมี client_email และ private_key');
102
+ const header = base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
103
+ const payload = base64Url(JSON.stringify({
104
+ iss: issuer,
105
+ scope: GOOGLE_CHAT_SCOPE,
106
+ aud: audience,
107
+ iat: now,
108
+ exp: now + 3600,
109
+ }));
110
+ const unsigned = `${header}.${payload}`;
111
+ const signature = createSign('RSA-SHA256').update(unsigned).sign(privateKey);
112
+ return `${unsigned}.${base64Url(signature)}`;
113
+ }
114
+ async function readGoogleChatJsonOrThrow(response, label, secrets = []) {
115
+ const text = await response.text().catch(() => '');
116
+ if (!response.ok)
117
+ throw new Error(`${label} ${response.status}${text ? `: ${redactGoogleChatDetail(text, secrets).slice(0, 240)}` : ''}`);
118
+ let json;
119
+ try {
120
+ json = (text ? JSON.parse(text) : {});
121
+ }
122
+ catch {
123
+ throw new Error(`${label} ${response.status}: response ไม่ใช่ JSON: ${redactGoogleChatDetail(text, secrets).slice(0, 240)}`);
124
+ }
125
+ const maybe = json;
126
+ const error = maybe.error;
127
+ if (error) {
128
+ const detail = typeof error === 'string'
129
+ ? maybe.error_description || error
130
+ : error.message || error.status || String(error.code ?? 'unknown');
131
+ throw new Error(`${label}: ${redactGoogleChatDetail(detail, secrets).slice(0, 200)}`);
132
+ }
133
+ return json;
134
+ }
135
+ export async function googleChatAccessToken(config) {
136
+ const serviceAccountPath = config.serviceAccountJson?.trim();
137
+ if (!serviceAccountPath)
138
+ throw new Error('ยังไม่ได้ตั้ง Google Chat service account JSON');
139
+ const serviceAccount = await readGoogleServiceAccount(serviceAccountPath);
140
+ const tokenUri = serviceAccount.token_uri?.trim() || GOOGLE_OAUTH_TOKEN_URI;
141
+ const assertion = googleServiceAccountJwt(serviceAccount);
142
+ const body = new URLSearchParams({
143
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
144
+ assertion,
145
+ });
146
+ const r = await fetch(tokenUri, {
147
+ method: 'POST',
148
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
149
+ body,
150
+ });
151
+ const json = await readGoogleChatJsonOrThrow(r, 'Google Chat OAuth token', [
152
+ serviceAccount.private_key,
153
+ assertion,
154
+ ]);
155
+ const token = json.access_token?.trim();
156
+ if (!token)
157
+ throw new Error('Google Chat OAuth token response ไม่มี access_token');
158
+ return token;
159
+ }
160
+ async function sendGoogleChatWebhook(webhookUrl, text) {
161
+ const messageIds = [];
162
+ for (const chunk of chunkGoogleChatText(text)) {
163
+ const r = await fetch(webhookUrl, {
164
+ method: 'POST',
165
+ headers: { 'content-type': 'application/json' },
166
+ body: JSON.stringify({ text: chunk }),
167
+ });
168
+ const json = await readGoogleChatJsonOrThrow(r, 'Google Chat incoming webhook', [webhookUrl]);
169
+ if (json.name)
170
+ messageIds.push(json.name);
171
+ }
172
+ return { mode: 'incoming_webhook', target: 'webhook', messageIds, messageCount: messageIds.length || chunkGoogleChatText(text).length };
173
+ }
174
+ async function sendGoogleChatApi(config, target, text) {
175
+ if (!target.space)
176
+ throw new Error('Google Chat API target ต้องเป็น space');
177
+ const token = await googleChatAccessToken(config);
178
+ const messageIds = [];
179
+ for (const chunk of chunkGoogleChatText(text)) {
180
+ const r = await fetch(googleChatApiUrl(config, `/v1/${target.space}/messages`), {
181
+ method: 'POST',
182
+ headers: {
183
+ authorization: `Bearer ${token}`,
184
+ 'content-type': 'application/json',
185
+ },
186
+ body: JSON.stringify({
187
+ text: chunk,
188
+ ...(target.thread ? { thread: { name: target.thread } } : {}),
189
+ }),
190
+ });
191
+ const json = await readGoogleChatJsonOrThrow(r, 'Google Chat API message', [token]);
192
+ if (json.name)
193
+ messageIds.push(json.name);
194
+ }
195
+ return {
196
+ mode: 'chat_api',
197
+ target: target.value,
198
+ messageIds,
199
+ messageCount: messageIds.length || chunkGoogleChatText(text).length,
200
+ };
201
+ }
202
+ export async function sendGoogleChatMessage(config, text, explicitTarget) {
203
+ const target = parseGoogleChatTarget(config, explicitTarget);
204
+ if (target.type === 'webhook')
205
+ return sendGoogleChatWebhook(target.value, text);
206
+ return sendGoogleChatApi(config, target, text);
207
+ }