sanook-cli 0.5.1 → 0.5.5

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 (217) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +148 -10
  3. package/README.md +255 -26
  4. package/README.th.md +95 -7
  5. package/dist/approval.js +13 -0
  6. package/dist/bin.js +3552 -155
  7. package/dist/brain-consolidate.js +335 -0
  8. package/dist/brain-context.js +262 -0
  9. package/dist/brain-doctor.js +318 -0
  10. package/dist/brain-eval.js +186 -0
  11. package/dist/brain-final.js +377 -0
  12. package/dist/brain-metrics.js +277 -0
  13. package/dist/brain-new.js +402 -0
  14. package/dist/brain-pack.js +210 -0
  15. package/dist/brain-repair.js +280 -0
  16. package/dist/brain-review.js +382 -0
  17. package/dist/brain.js +15 -1
  18. package/dist/brand.js +1 -1
  19. package/dist/cli-args.js +190 -0
  20. package/dist/cli-option-values.js +16 -0
  21. package/dist/clipboard.js +65 -0
  22. package/dist/commands.js +266 -27
  23. package/dist/compaction.js +96 -11
  24. package/dist/config.js +149 -33
  25. package/dist/context-compression.js +191 -0
  26. package/dist/context-pack.js +145 -0
  27. package/dist/cost.js +49 -15
  28. package/dist/dashboard/api-helpers.js +87 -0
  29. package/dist/dashboard/server.js +179 -0
  30. package/dist/dashboard/static/app.js +277 -0
  31. package/dist/dashboard/static/index.html +39 -0
  32. package/dist/dashboard/static/styles.css +85 -0
  33. package/dist/diff.js +10 -2
  34. package/dist/first-run.js +21 -0
  35. package/dist/gateway/auth.js +49 -9
  36. package/dist/gateway/bluebubbles.js +205 -0
  37. package/dist/gateway/config.js +929 -0
  38. package/dist/gateway/deliver.js +399 -0
  39. package/dist/gateway/discord.js +124 -0
  40. package/dist/gateway/doctor.js +456 -0
  41. package/dist/gateway/email.js +501 -0
  42. package/dist/gateway/googlechat.js +207 -0
  43. package/dist/gateway/homeassistant.js +256 -0
  44. package/dist/gateway/ledger.js +38 -1
  45. package/dist/gateway/line.js +171 -0
  46. package/dist/gateway/lock.js +3 -1
  47. package/dist/gateway/matrix.js +366 -0
  48. package/dist/gateway/mattermost.js +322 -0
  49. package/dist/gateway/ntfy.js +218 -0
  50. package/dist/gateway/schedule.js +31 -4
  51. package/dist/gateway/serve.js +267 -7
  52. package/dist/gateway/server.js +253 -19
  53. package/dist/gateway/service.js +224 -0
  54. package/dist/gateway/session.js +362 -0
  55. package/dist/gateway/signal.js +351 -0
  56. package/dist/gateway/slack.js +124 -0
  57. package/dist/gateway/sms.js +169 -0
  58. package/dist/gateway/targets.js +576 -0
  59. package/dist/gateway/teams.js +106 -0
  60. package/dist/gateway/telegram.js +38 -15
  61. package/dist/gateway/webhooks.js +220 -0
  62. package/dist/gateway/whatsapp.js +230 -0
  63. package/dist/hooks.js +13 -2
  64. package/dist/hotkeys.js +21 -0
  65. package/dist/i18n/en.js +98 -0
  66. package/dist/i18n/index.js +19 -0
  67. package/dist/i18n/th.js +98 -0
  68. package/dist/i18n/types.js +1 -0
  69. package/dist/insights-args.js +55 -0
  70. package/dist/insights.js +86 -0
  71. package/dist/knowledge.js +55 -29
  72. package/dist/loop.js +157 -29
  73. package/dist/lsp/index.js +23 -5
  74. package/dist/mcp-hub.js +33 -0
  75. package/dist/mcp-registry.js +494 -0
  76. package/dist/mcp-risk.js +71 -0
  77. package/dist/mcp-server.js +1 -1
  78. package/dist/mcp.js +120 -10
  79. package/dist/memory-log.js +90 -0
  80. package/dist/memory-store.js +37 -1
  81. package/dist/memory.js +148 -37
  82. package/dist/model-picker.js +58 -0
  83. package/dist/orchestrate.js +51 -19
  84. package/dist/personality.js +58 -0
  85. package/dist/plan-handoff.js +17 -0
  86. package/dist/polyglot.js +162 -0
  87. package/dist/process-runner.js +96 -0
  88. package/dist/project-init.js +91 -0
  89. package/dist/project-registry.js +143 -0
  90. package/dist/project-scaffold.js +124 -0
  91. package/dist/prompt-size.js +155 -0
  92. package/dist/providers/codex-login.js +138 -0
  93. package/dist/providers/codex.js +89 -43
  94. package/dist/providers/keys.js +22 -1
  95. package/dist/providers/models.js +2 -2
  96. package/dist/providers/registry.js +14 -47
  97. package/dist/search/chunk.js +7 -8
  98. package/dist/search/cli.js +83 -0
  99. package/dist/search/embed-store.js +3 -0
  100. package/dist/search/embedding-config.js +22 -0
  101. package/dist/search/engine.js +2 -13
  102. package/dist/search/indexer.js +44 -1
  103. package/dist/search/store.js +23 -1
  104. package/dist/session-distill.js +84 -0
  105. package/dist/session.js +92 -16
  106. package/dist/skill-install.js +53 -13
  107. package/dist/skills.js +33 -0
  108. package/dist/slash-completion.js +155 -0
  109. package/dist/support-dump.js +206 -0
  110. package/dist/tool-catalog.js +59 -0
  111. package/dist/tools/edit.js +45 -15
  112. package/dist/tools/git.js +10 -5
  113. package/dist/tools/homeassistant.js +106 -0
  114. package/dist/tools/index.js +10 -0
  115. package/dist/tools/list.js +19 -6
  116. package/dist/tools/permission.js +992 -12
  117. package/dist/tools/polyglot.js +126 -0
  118. package/dist/tools/read.js +16 -4
  119. package/dist/tools/sandbox.js +38 -13
  120. package/dist/tools/schedule.js +19 -3
  121. package/dist/tools/search.js +226 -15
  122. package/dist/tools/task.js +40 -9
  123. package/dist/tools/timeout.js +23 -3
  124. package/dist/tools/web-fetch-tool.js +33 -0
  125. package/dist/trust.js +11 -1
  126. package/dist/turn-retrieval.js +83 -0
  127. package/dist/ui/app.js +878 -32
  128. package/dist/ui/banner.js +78 -4
  129. package/dist/ui/history.js +37 -5
  130. package/dist/ui/markdown.js +122 -0
  131. package/dist/ui/mentions.js +3 -2
  132. package/dist/ui/overlay.js +496 -0
  133. package/dist/ui/queue.js +23 -0
  134. package/dist/ui/render.js +20 -1
  135. package/dist/ui/session-panel.js +115 -0
  136. package/dist/ui/setup-providers.js +40 -0
  137. package/dist/ui/setup.js +172 -46
  138. package/dist/ui/status.js +142 -0
  139. package/dist/ui/thinking-panel.js +36 -0
  140. package/dist/ui/tool-trail.js +97 -0
  141. package/dist/ui/transcript.js +26 -0
  142. package/dist/ui/useBusyElapsed.js +19 -0
  143. package/dist/ui/useEditor.js +144 -5
  144. package/dist/ui/useGitBranch.js +57 -0
  145. package/dist/update.js +56 -17
  146. package/dist/web-fetch.js +637 -0
  147. package/dist/web-surface.js +190 -0
  148. package/dist/worktree.js +175 -4
  149. package/package.json +5 -5
  150. package/second-brain/AGENTS.md +6 -4
  151. package/second-brain/CLAUDE.md +7 -1
  152. package/second-brain/Evals/_Index.md +10 -2
  153. package/second-brain/Evals/quality-ledger.md +9 -1
  154. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  155. package/second-brain/GEMINI.md +5 -4
  156. package/second-brain/Home.md +1 -1
  157. package/second-brain/Projects/_Index.md +19 -4
  158. package/second-brain/Projects/sanook-cli/_Index.md +30 -0
  159. package/second-brain/Projects/sanook-cli/context.md +35 -0
  160. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  161. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  162. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  163. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +197 -0
  164. package/second-brain/README.md +1 -1
  165. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  166. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  167. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  168. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  169. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  170. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  171. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  172. package/second-brain/Research/_Index.md +8 -1
  173. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  174. package/second-brain/Reviews/_Index.md +1 -1
  175. package/second-brain/Runbooks/_Index.md +6 -1
  176. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  177. package/second-brain/SANOOK.md +45 -0
  178. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  179. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  180. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  181. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  182. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  183. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  184. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  185. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  186. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  187. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  188. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  189. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  190. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  191. package/second-brain/Sessions/_Index.md +15 -1
  192. package/second-brain/Shared/AI-Context-Index.md +22 -0
  193. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  194. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  195. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  196. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  197. package/second-brain/Shared/Operating-State/current-state.md +14 -4
  198. package/second-brain/Shared/Scripts/_Index.md +3 -1
  199. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  200. package/second-brain/Shared/Tech-Standards/_Index.md +6 -1
  201. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  202. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  203. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  204. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  205. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  206. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  207. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  208. package/second-brain/Templates/_Index.md +9 -0
  209. package/second-brain/Templates/final-lite.md +111 -0
  210. package/second-brain/Templates/final.md +231 -0
  211. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  212. package/second-brain/Templates/project-workspace/context.md +28 -0
  213. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  214. package/second-brain/Templates/project-workspace/overview.md +39 -0
  215. package/second-brain/Templates/project-workspace/repo.md +33 -0
  216. package/second-brain/Vault Structure Map.md +2 -1
  217. package/skills/structured-output-llm/SKILL.md +1 -1
@@ -0,0 +1,351 @@
1
+ import { BRAND } from '../brand.js';
2
+ import { redactKey } from '../providers/keys.js';
3
+ import { runGatewayAgent } from './session.js';
4
+ const SIGNAL_TEXT_LIMIT = 8000;
5
+ const SIGNAL_DEFAULT_HTTP_URL = 'http://127.0.0.1:8080';
6
+ const runningTargets = new Set();
7
+ const recentSentTimestamps = new Set();
8
+ export function normalizeSignalId(raw) {
9
+ const trimmed = raw?.trim();
10
+ if (!trimmed)
11
+ return undefined;
12
+ if (trimmed.toLowerCase().startsWith('group:')) {
13
+ const groupId = trimmed.slice(trimmed.indexOf(':') + 1).trim();
14
+ return groupId ? `group:${groupId}` : undefined;
15
+ }
16
+ if (/^\+?[\d\s().-]+$/.test(trimmed))
17
+ return trimmed.replace(/[\s().-]+/g, '');
18
+ return trimmed;
19
+ }
20
+ export function redactSignalId(raw) {
21
+ const normalized = normalizeSignalId(raw);
22
+ if (!normalized)
23
+ return '(not set)';
24
+ if (normalized === '*')
25
+ return '*';
26
+ const prefix = normalized.startsWith('group:') ? 'group:' : '';
27
+ const value = prefix ? normalized.slice(prefix.length) : normalized;
28
+ if (value.length <= 6)
29
+ return `${prefix}<redacted>`;
30
+ if (value.startsWith('+') && value.length >= 8)
31
+ return `${value.slice(0, 4)}…${value.slice(-4)}`;
32
+ return `${prefix}${value.slice(0, 4)}…${value.slice(-4)}`;
33
+ }
34
+ export function signalHttpUrl(httpUrl) {
35
+ return (httpUrl?.trim() || SIGNAL_DEFAULT_HTTP_URL).replace(/\/+$/, '');
36
+ }
37
+ export function signalRpcUrl(httpUrl) {
38
+ return `${signalHttpUrl(httpUrl)}/api/v1/rpc`;
39
+ }
40
+ export function signalEventsUrl(httpUrl, account) {
41
+ return `${signalHttpUrl(httpUrl)}/api/v1/events?account=${encodeURIComponent(account)}`;
42
+ }
43
+ export function signalCheckUrl(httpUrl) {
44
+ return `${signalHttpUrl(httpUrl)}/api/v1/check`;
45
+ }
46
+ export function splitSignalText(raw, limit = SIGNAL_TEXT_LIMIT) {
47
+ let remaining = raw.trim() || '(ไม่มีผลลัพธ์)';
48
+ const chunks = [];
49
+ while (remaining.length > limit) {
50
+ const window = remaining.slice(0, limit + 1);
51
+ let cut = window.lastIndexOf('\n');
52
+ if (cut < Math.floor(limit * 0.4))
53
+ cut = window.lastIndexOf(' ');
54
+ if (cut < Math.floor(limit * 0.4))
55
+ cut = limit;
56
+ chunks.push(remaining.slice(0, cut).trim());
57
+ remaining = remaining.slice(cut).trimStart();
58
+ }
59
+ if (remaining)
60
+ chunks.push(remaining);
61
+ return chunks.length ? chunks : ['(ไม่มีผลลัพธ์)'];
62
+ }
63
+ function rememberSentTimestamp(timestamp) {
64
+ if (timestamp == null)
65
+ return;
66
+ const key = String(timestamp);
67
+ recentSentTimestamps.add(key);
68
+ setTimeout(() => recentSentTimestamps.delete(key), 5 * 60_000).unref?.();
69
+ }
70
+ export async function signalRpc(config, method, params, rpcId = `${Date.now()}-${Math.random().toString(16).slice(2)}`) {
71
+ const account = config.account?.trim();
72
+ const body = {
73
+ jsonrpc: '2.0',
74
+ method,
75
+ params: account && params.account == null ? { account, ...params } : params,
76
+ id: rpcId,
77
+ };
78
+ const r = await fetch(signalRpcUrl(config.httpUrl), {
79
+ method: 'POST',
80
+ headers: { 'content-type': 'application/json' },
81
+ body: JSON.stringify(body),
82
+ });
83
+ if (!r.ok) {
84
+ const detail = await r.text().catch(() => '');
85
+ throw new Error(`Signal JSON-RPC ${r.status}${detail ? `: ${redactKey(detail).slice(0, 200)}` : ''}`);
86
+ }
87
+ const parsed = (await r.json().catch(() => ({})));
88
+ if (parsed.error) {
89
+ const message = typeof parsed.error === 'string' ? parsed.error : parsed.error.message ?? JSON.stringify(parsed.error);
90
+ throw new Error(`Signal JSON-RPC error: ${redactKey(message)}`);
91
+ }
92
+ return parsed.result;
93
+ }
94
+ export async function sendSignalMessage(config, target, text) {
95
+ const account = normalizeSignalId(config.account);
96
+ const to = normalizeSignalId(target);
97
+ if (!config.httpUrl || !account)
98
+ throw new Error('Signal config ต้องมี httpUrl และ account');
99
+ if (!to)
100
+ throw new Error('Signal recipient ว่าง');
101
+ const chunks = splitSignalText(text);
102
+ const messageIds = [];
103
+ for (const chunk of chunks) {
104
+ const params = to.startsWith('group:')
105
+ ? { account, groupId: to.slice('group:'.length), message: chunk }
106
+ : { account, recipient: [to], message: chunk };
107
+ const result = (await signalRpc({ httpUrl: config.httpUrl, account }, 'send', params));
108
+ const timestamp = typeof result === 'object' && result ? result.timestamp : result;
109
+ if (timestamp != null) {
110
+ const id = String(timestamp);
111
+ messageIds.push(id);
112
+ rememberSentTimestamp(id);
113
+ }
114
+ }
115
+ return { to, messageCount: chunks.length, messageIds };
116
+ }
117
+ export function parseSignalSseLine(line) {
118
+ const trimmed = line.trimEnd();
119
+ if (!trimmed || trimmed.startsWith(':') || !trimmed.startsWith('data:'))
120
+ return null;
121
+ const data = trimmed.slice('data:'.length).trimStart();
122
+ if (!data)
123
+ return null;
124
+ return JSON.parse(data);
125
+ }
126
+ function objectRecord(value) {
127
+ return value && typeof value === 'object' ? value : undefined;
128
+ }
129
+ function stringField(record, ...keys) {
130
+ for (const key of keys) {
131
+ const value = record?.[key];
132
+ if (typeof value === 'string' && value.trim())
133
+ return value.trim();
134
+ }
135
+ return undefined;
136
+ }
137
+ function numberOrStringField(record, ...keys) {
138
+ for (const key of keys) {
139
+ const value = record?.[key];
140
+ if (typeof value === 'number' || (typeof value === 'string' && value.trim()))
141
+ return value;
142
+ }
143
+ return undefined;
144
+ }
145
+ export function signalEnvelopeMessage(raw, account) {
146
+ const outer = objectRecord(raw);
147
+ const envelope = objectRecord(outer?.envelope) ?? outer;
148
+ if (!envelope)
149
+ return null;
150
+ if (objectRecord(envelope.storyMessage))
151
+ return null;
152
+ const sync = objectRecord(objectRecord(envelope.syncMessage)?.sentMessage);
153
+ if (sync) {
154
+ const data = objectRecord(sync.dataMessage) ?? sync;
155
+ const groupId = stringField(objectRecord(data.groupInfo), 'groupId');
156
+ const destination = normalizeSignalId(stringField(sync, 'destination', 'destinationNumber', 'recipient'));
157
+ const self = normalizeSignalId(account);
158
+ const text = stringField(data, 'message');
159
+ if (!text)
160
+ return null;
161
+ if (!groupId && (!self || destination !== self))
162
+ return null;
163
+ const target = groupId ? `group:${groupId}` : self;
164
+ if (!target)
165
+ return null;
166
+ return {
167
+ target,
168
+ sender: self,
169
+ text,
170
+ groupId,
171
+ timestamp: numberOrStringField(sync, 'timestamp') ?? numberOrStringField(envelope, 'timestamp'),
172
+ noteToSelf: !groupId,
173
+ };
174
+ }
175
+ const dataMessage = objectRecord(envelope.dataMessage) ?? objectRecord(objectRecord(envelope.editMessage)?.dataMessage);
176
+ if (!dataMessage)
177
+ return null;
178
+ const text = stringField(dataMessage, 'message');
179
+ if (!text)
180
+ return null;
181
+ const groupId = stringField(objectRecord(dataMessage.groupInfo), 'groupId');
182
+ const sender = normalizeSignalId(stringField(envelope, 'sourceNumber', 'sourceUuid', 'source'));
183
+ const target = groupId ? `group:${groupId}` : sender;
184
+ if (!target)
185
+ return null;
186
+ return {
187
+ target,
188
+ sender,
189
+ text,
190
+ groupId,
191
+ timestamp: numberOrStringField(envelope, 'timestamp') ?? numberOrStringField(dataMessage, 'timestamp'),
192
+ };
193
+ }
194
+ export function isAllowedSignalSource(config, event) {
195
+ if (event.groupId) {
196
+ const allowed = config.groupAllowedUsers.map((id) => normalizeSignalId(id) ?? id.trim()).filter(Boolean);
197
+ return allowed.includes('*') || allowed.includes(event.groupId) || allowed.includes(`group:${event.groupId}`);
198
+ }
199
+ const sender = normalizeSignalId(event.sender ?? event.target);
200
+ if (!sender)
201
+ return false;
202
+ if (config.allowAllUsers)
203
+ return true;
204
+ if (sender === normalizeSignalId(config.homeChannel))
205
+ return true;
206
+ return config.allowedUsers.map(normalizeSignalId).includes(sender);
207
+ }
208
+ function signalPrompt(event) {
209
+ if (event.groupId) {
210
+ const sender = event.sender ? ` from ${redactSignalId(event.sender)}` : '';
211
+ return [`Signal group ${redactSignalId(event.target)}${sender}:`, event.text.trim()].join('\n');
212
+ }
213
+ return [`Signal from ${redactSignalId(event.sender ?? event.target)}:`, event.text.trim()].join('\n');
214
+ }
215
+ function signalMentioned(config, event) {
216
+ if (!event.groupId || !config.requireMention)
217
+ return true;
218
+ const account = normalizeSignalId(config.account);
219
+ return Boolean(account && event.text.includes(account));
220
+ }
221
+ export async function handleSignalEvent(opts) {
222
+ const event = opts.event;
223
+ const text = event.text.trim();
224
+ if (!text)
225
+ return { handled: false, reason: 'empty' };
226
+ if (event.timestamp != null && recentSentTimestamps.has(String(event.timestamp)))
227
+ return { handled: false, reason: 'self_message' };
228
+ const account = normalizeSignalId(opts.config.account);
229
+ if (account && normalizeSignalId(event.sender) === account && !event.noteToSelf)
230
+ return { handled: false, reason: 'self_message' };
231
+ if (!isAllowedSignalSource(opts.config, event)) {
232
+ opts.onLog?.(`Signal: ปฏิเสธ target ${redactSignalId(event.target)} (ไม่อยู่ใน allowlist)`);
233
+ return { handled: false, reason: 'not_allowed' };
234
+ }
235
+ if (!signalMentioned(opts.config, event))
236
+ return { handled: false, reason: 'not_mentioned' };
237
+ const running = opts.runningTargets ?? runningTargets;
238
+ if (running.has(event.target))
239
+ return { handled: false, reason: 'busy' };
240
+ running.add(event.target);
241
+ try {
242
+ const result = await runGatewayAgent({
243
+ platform: 'signal',
244
+ target: event.target,
245
+ model: opts.model,
246
+ prompt: signalPrompt(event),
247
+ userText: text,
248
+ budgetUsd: opts.budgetUsd,
249
+ permissionMode: opts.permissionMode ?? 'ask',
250
+ });
251
+ if (!result.suppressDelivery)
252
+ await sendSignalMessage(opts.config, event.target, result.text || '(ไม่มีผลลัพธ์)');
253
+ return { handled: true };
254
+ }
255
+ catch (e) {
256
+ opts.onLog?.(`Signal run error (${redactSignalId(event.target)}): ${redactKey(e.message)}`);
257
+ await sendSignalMessage(opts.config, event.target, 'เกิดข้อผิดพลาดภายใน').catch(() => { });
258
+ return { handled: false, reason: 'error' };
259
+ }
260
+ finally {
261
+ running.delete(event.target);
262
+ }
263
+ }
264
+ async function delay(ms, signal) {
265
+ if (signal.aborted)
266
+ return;
267
+ await new Promise((resolve) => {
268
+ const timer = setTimeout(resolve, ms);
269
+ signal.addEventListener('abort', () => {
270
+ clearTimeout(timer);
271
+ resolve();
272
+ }, { once: true });
273
+ });
274
+ }
275
+ async function readSignalStream(response, opts, signal) {
276
+ if (!response.body)
277
+ throw new Error('Signal stream ไม่มี response body');
278
+ const reader = response.body.getReader();
279
+ const decoder = new TextDecoder();
280
+ let pending = '';
281
+ let dataLines = [];
282
+ const flush = async () => {
283
+ if (!dataLines.length || signal.aborted)
284
+ return;
285
+ const raw = dataLines.join('\n');
286
+ dataLines = [];
287
+ const parsed = JSON.parse(raw);
288
+ const event = signalEnvelopeMessage(parsed, opts.config.account);
289
+ if (event)
290
+ await handleSignalEvent({ ...opts, event, runningTargets });
291
+ };
292
+ for (;;) {
293
+ const { done, value } = await reader.read();
294
+ pending += decoder.decode(value, { stream: !done });
295
+ const lines = pending.split(/\r?\n/);
296
+ pending = lines.pop() ?? '';
297
+ for (const line of lines) {
298
+ if (signal.aborted)
299
+ return;
300
+ if (!line.trim()) {
301
+ await flush();
302
+ }
303
+ else if (line.startsWith('data:')) {
304
+ dataLines.push(line.slice('data:'.length).trimStart());
305
+ }
306
+ }
307
+ if (done)
308
+ break;
309
+ }
310
+ if (pending.startsWith('data:'))
311
+ dataLines.push(pending.slice('data:'.length).trimStart());
312
+ await flush();
313
+ }
314
+ export function startSignal(opts) {
315
+ const account = normalizeSignalId(opts.config.account);
316
+ if (!account) {
317
+ opts.onLog?.('Signal ไม่เริ่ม: ต้องตั้ง SIGNAL_ACCOUNT หรือ gateway setup signal --account <+E.164>');
318
+ return () => { };
319
+ }
320
+ if (!opts.config.allowAllUsers && !opts.config.homeChannel && !opts.config.allowedUsers.length && !opts.config.groupAllowedUsers.length) {
321
+ opts.onLog?.('Signal ไม่เริ่ม: ต้องตั้ง home channel หรือ allowlist เพื่อ fail-closed');
322
+ return () => { };
323
+ }
324
+ const controller = new AbortController();
325
+ const reconnectMs = opts.reconnectMs ?? 5000;
326
+ const loop = async () => {
327
+ opts.onLog?.(`Signal: subscribe ${signalEventsUrl(opts.config.httpUrl, account)}`);
328
+ while (!controller.signal.aborted) {
329
+ try {
330
+ const health = await fetch(signalCheckUrl(opts.config.httpUrl), { signal: controller.signal });
331
+ if (!health.ok)
332
+ throw new Error(`Signal health ${health.status}`);
333
+ const r = await fetch(signalEventsUrl(opts.config.httpUrl, account), {
334
+ method: 'GET',
335
+ headers: { accept: 'text/event-stream' },
336
+ signal: controller.signal,
337
+ });
338
+ if (!r.ok)
339
+ throw new Error(`Signal events ${r.status}`);
340
+ await readSignalStream(r, opts, controller.signal);
341
+ }
342
+ catch (e) {
343
+ if (!controller.signal.aborted)
344
+ opts.onLog?.(`Signal stream error: ${redactKey(e.message)}; reconnecting`);
345
+ }
346
+ await delay(reconnectMs, controller.signal);
347
+ }
348
+ };
349
+ void loop();
350
+ return () => controller.abort();
351
+ }
@@ -0,0 +1,124 @@
1
+ import { redactKey } from '../providers/keys.js';
2
+ import { runGatewayAgent } from './session.js';
3
+ export async function sendSlackMessage(botToken, channelId, text, threadTs) {
4
+ const r = await fetch('https://slack.com/api/chat.postMessage', {
5
+ method: 'POST',
6
+ headers: {
7
+ authorization: `Bearer ${botToken}`,
8
+ 'content-type': 'application/json; charset=utf-8',
9
+ },
10
+ body: JSON.stringify({
11
+ channel: channelId,
12
+ text: text.slice(0, 40_000),
13
+ ...(threadTs ? { thread_ts: threadTs } : {}),
14
+ }),
15
+ });
16
+ if (!r.ok)
17
+ throw new Error(`Slack chat.postMessage ${r.status}`);
18
+ const body = (await r.json().catch(() => ({})));
19
+ if (body.ok !== true)
20
+ throw new Error(`Slack chat.postMessage error: ${body.error ?? 'unknown'}`);
21
+ return { channelId: body.channel ?? channelId, messageTs: body.ts };
22
+ }
23
+ async function openSocketUrl(appToken) {
24
+ const r = await fetch('https://slack.com/api/apps.connections.open', {
25
+ method: 'POST',
26
+ headers: { authorization: `Bearer ${appToken}` },
27
+ });
28
+ if (!r.ok)
29
+ throw new Error(`Slack apps.connections.open ${r.status}`);
30
+ const body = (await r.json().catch(() => ({})));
31
+ if (body.ok !== true || !body.url)
32
+ throw new Error(`Slack apps.connections.open error: ${body.error ?? 'missing_url'}`);
33
+ return body.url;
34
+ }
35
+ function defaultWebSocketFactory(url) {
36
+ const WS = globalThis.WebSocket;
37
+ if (!WS)
38
+ throw new Error('WebSocket runtime ไม่พร้อมใช้งานใน Node นี้');
39
+ return new WS(url);
40
+ }
41
+ function allowed(channelId, allowedChannelIds, defaultChannelId) {
42
+ const allow = allowedChannelIds?.length ? allowedChannelIds : defaultChannelId ? [defaultChannelId] : [];
43
+ return allow.includes(channelId);
44
+ }
45
+ export async function startSlack(opts) {
46
+ const allowedChannelIds = opts.allowedChannelIds?.filter(Boolean) ?? [];
47
+ if (!allowedChannelIds.length && !opts.defaultChannelId) {
48
+ opts.onLog?.('⛔ Slack ไม่เริ่ม: ต้องตั้ง default channel หรือ allowed channels เพื่อ fail-closed');
49
+ return () => { };
50
+ }
51
+ const url = await openSocketUrl(opts.appToken);
52
+ const ws = (opts.webSocketFactory ?? defaultWebSocketFactory)(url);
53
+ const running = new Set();
54
+ let stopped = false;
55
+ ws.addEventListener('open', () => {
56
+ opts.onLog?.(`Slack: Socket Mode connected (allowlist ${allowedChannelIds.length || 1} channel)`);
57
+ });
58
+ ws.addEventListener('message', (event) => {
59
+ let envelope;
60
+ try {
61
+ envelope = JSON.parse(String(event.data));
62
+ }
63
+ catch {
64
+ return;
65
+ }
66
+ if (envelope.envelope_id)
67
+ ws.send(JSON.stringify({ envelope_id: envelope.envelope_id }));
68
+ if (envelope.type !== 'events_api')
69
+ return;
70
+ const ev = envelope.payload?.event;
71
+ if (!ev || (ev.type !== 'message' && ev.type !== 'app_mention'))
72
+ return;
73
+ if (ev.subtype || ev.bot_id)
74
+ return;
75
+ const channelId = ev.channel;
76
+ const text = ev.text?.trim();
77
+ if (!channelId || !text)
78
+ return;
79
+ if (!allowed(channelId, allowedChannelIds, opts.defaultChannelId)) {
80
+ opts.onLog?.(`Slack: ปฏิเสธ channel ${channelId} (ไม่อยู่ใน allowlist)`);
81
+ return;
82
+ }
83
+ const threadTs = ev.thread_ts ?? ev.ts;
84
+ const sessionTarget = `${channelId}:${ev.user ?? 'unknown'}`;
85
+ const userText = text.replace(/<@[A-Z0-9]+>/g, '').trim() || text;
86
+ if (running.has(sessionTarget)) {
87
+ void sendSlackMessage(opts.botToken, channelId, 'กำลังทำงานก่อนหน้าอยู่ รอสักครู่', threadTs).catch(() => { });
88
+ return;
89
+ }
90
+ running.add(sessionTarget);
91
+ void (async () => {
92
+ try {
93
+ await sendSlackMessage(opts.botToken, channelId, 'กำลังคิด...', threadTs);
94
+ const out = await runGatewayAgent({
95
+ platform: 'slack',
96
+ target: sessionTarget,
97
+ model: opts.model,
98
+ prompt: userText,
99
+ userText,
100
+ budgetUsd: opts.budgetUsd,
101
+ permissionMode: opts.allowWrite === true ? 'auto' : 'ask',
102
+ });
103
+ if (!out.suppressDelivery && out.text.trim())
104
+ await sendSlackMessage(opts.botToken, channelId, out.text, threadTs);
105
+ }
106
+ catch (e) {
107
+ opts.onLog?.(`Slack run error (${channelId}): ${redactKey(e.message)}`);
108
+ await sendSlackMessage(opts.botToken, channelId, 'เกิดข้อผิดพลาดภายใน', threadTs).catch(() => { });
109
+ }
110
+ finally {
111
+ running.delete(sessionTarget);
112
+ }
113
+ })();
114
+ });
115
+ ws.addEventListener('close', () => {
116
+ if (!stopped)
117
+ opts.onLog?.('Slack: Socket Mode closed');
118
+ });
119
+ ws.addEventListener('error', () => opts.onLog?.('Slack: Socket Mode error'));
120
+ return () => {
121
+ stopped = true;
122
+ ws.close();
123
+ };
124
+ }
@@ -0,0 +1,169 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { redactKey } from '../providers/keys.js';
3
+ import { runGatewayAgent } from './session.js';
4
+ const SMS_TEXT_LIMIT = 1600;
5
+ const TWILIO_API_BASE = 'https://api.twilio.com/2010-04-01';
6
+ const runningTargets = new Set();
7
+ export function normalizeSmsPhone(raw) {
8
+ const trimmed = raw?.trim();
9
+ if (!trimmed)
10
+ return undefined;
11
+ return trimmed.replace(/[\s().-]+/g, '');
12
+ }
13
+ export function smsPlainText(raw) {
14
+ return raw
15
+ .replace(/\r\n/g, '\n')
16
+ .replace(/```[a-zA-Z0-9_-]*\n?/g, '')
17
+ .replace(/```/g, '')
18
+ .replace(/\[([^\]]+)]\(([^)]+)\)/g, '$1 ($2)')
19
+ .replace(/^#{1,6}\s+/gm, '')
20
+ .replace(/[*_`~]/g, '')
21
+ .trim();
22
+ }
23
+ export function splitSmsText(raw, limit = SMS_TEXT_LIMIT) {
24
+ let remaining = smsPlainText(raw) || '(ไม่มีผลลัพธ์)';
25
+ const chunks = [];
26
+ while (remaining.length > limit) {
27
+ const window = remaining.slice(0, limit + 1);
28
+ let cut = window.lastIndexOf('\n');
29
+ if (cut < Math.floor(limit * 0.4))
30
+ cut = window.lastIndexOf(' ');
31
+ if (cut < Math.floor(limit * 0.4))
32
+ cut = limit;
33
+ chunks.push(remaining.slice(0, cut).trim());
34
+ remaining = remaining.slice(cut).trimStart();
35
+ }
36
+ if (remaining)
37
+ chunks.push(remaining);
38
+ return chunks.length ? chunks : ['(ไม่มีผลลัพธ์)'];
39
+ }
40
+ export async function sendSmsMessage(config, to, text) {
41
+ const accountSid = config.accountSid?.trim();
42
+ const authToken = config.authToken?.trim();
43
+ const from = normalizeSmsPhone(config.phoneNumber);
44
+ const recipient = normalizeSmsPhone(to);
45
+ if (!accountSid || !authToken || !from)
46
+ throw new Error('Twilio SMS config ต้องมี accountSid, authToken และ phoneNumber');
47
+ if (!recipient)
48
+ throw new Error('SMS recipient ว่าง');
49
+ const url = `${TWILIO_API_BASE}/Accounts/${encodeURIComponent(accountSid)}/Messages.json`;
50
+ const auth = Buffer.from(`${accountSid}:${authToken}`, 'utf8').toString('base64');
51
+ const chunks = splitSmsText(text);
52
+ const messageIds = [];
53
+ for (const bodyText of chunks) {
54
+ const body = new URLSearchParams({ From: from, To: recipient, Body: bodyText });
55
+ const r = await fetch(url, {
56
+ method: 'POST',
57
+ headers: {
58
+ authorization: `Basic ${auth}`,
59
+ 'content-type': 'application/x-www-form-urlencoded',
60
+ },
61
+ body,
62
+ });
63
+ if (!r.ok) {
64
+ const detail = await r.text().catch(() => '');
65
+ throw new Error(`Twilio SMS ${r.status}${detail ? `: ${redactKey(detail).slice(0, 200)}` : ''}`);
66
+ }
67
+ const parsed = (await r.json().catch(() => ({})));
68
+ if (parsed.sid)
69
+ messageIds.push(parsed.sid);
70
+ }
71
+ return { to: recipient, messageCount: messageIds.length || chunks.length, messageIds };
72
+ }
73
+ export function parseTwilioForm(rawBody) {
74
+ return new URLSearchParams(rawBody);
75
+ }
76
+ export function verifyTwilioSignature(authToken, webhookUrl, params, signature) {
77
+ if (!authToken || !webhookUrl || !signature)
78
+ return false;
79
+ const grouped = new Map();
80
+ for (const [key, value] of params) {
81
+ const list = grouped.get(key) ?? [];
82
+ list.push(value);
83
+ grouped.set(key, list);
84
+ }
85
+ let payload = webhookUrl;
86
+ for (const key of [...grouped.keys()].sort()) {
87
+ for (const value of (grouped.get(key) ?? []).sort())
88
+ payload += `${key}${value}`;
89
+ }
90
+ const expected = createHmac('sha1', authToken).update(payload).digest('base64');
91
+ const a = Buffer.from(signature.trim());
92
+ const b = Buffer.from(expected);
93
+ return a.length === b.length && timingSafeEqual(a, b);
94
+ }
95
+ export function isAllowedSmsSender(config, from) {
96
+ if (config.allowAllUsers)
97
+ return true;
98
+ const sender = normalizeSmsPhone(from);
99
+ if (!sender)
100
+ return false;
101
+ if (sender === normalizeSmsPhone(config.homeChannel))
102
+ return true;
103
+ return config.allowedUsers.map(normalizeSmsPhone).includes(sender);
104
+ }
105
+ function escapeXml(text) {
106
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
107
+ }
108
+ export function smsTwiml(messages = []) {
109
+ const body = messages
110
+ .flatMap((message) => splitSmsText(message))
111
+ .map((message) => `<Message>${escapeXml(message)}</Message>`)
112
+ .join('');
113
+ return `<?xml version="1.0" encoding="UTF-8"?><Response>${body}</Response>`;
114
+ }
115
+ function smsPrompt(params, from, to) {
116
+ return [`SMS from ${from} to ${to}:`, params.get('Body')?.trim() || '(empty)'].join('\n');
117
+ }
118
+ function xml(status, messages = []) {
119
+ return { status, body: smsTwiml(messages), contentType: 'application/xml; charset=utf-8' };
120
+ }
121
+ export async function handleSmsWebhook(opts) {
122
+ const cfg = opts.config;
123
+ if (!cfg.accountSid || !cfg.authToken || !cfg.phoneNumber)
124
+ return xml(503);
125
+ if (!cfg.insecureNoSignature && !cfg.webhookUrl)
126
+ return xml(503);
127
+ const params = parseTwilioForm(opts.rawBody);
128
+ if (!cfg.insecureNoSignature && !verifyTwilioSignature(cfg.authToken, cfg.webhookUrl, params, opts.signature)) {
129
+ return xml(401);
130
+ }
131
+ const from = normalizeSmsPhone(params.get('From') ?? undefined);
132
+ const to = normalizeSmsPhone(params.get('To') ?? undefined);
133
+ const body = params.get('Body')?.trim();
134
+ const messageSid = params.get('MessageSid')?.trim();
135
+ if (!from || !to || !body)
136
+ return xml(200);
137
+ if (from === normalizeSmsPhone(cfg.phoneNumber)) {
138
+ opts.onLog?.(`SMS: ข้ามข้อความจากเบอร์ Twilio เอง ${from}`);
139
+ return xml(200);
140
+ }
141
+ if (!isAllowedSmsSender(cfg, from)) {
142
+ opts.onLog?.(`SMS: ปฏิเสธ sender ${from} (ไม่อยู่ใน allowlist)`);
143
+ return xml(200, ['ไม่ได้รับอนุญาตให้ใช้ bot นี้']);
144
+ }
145
+ if (runningTargets.has(from))
146
+ return xml(200, ['กำลังทำงานก่อนหน้าอยู่ รอสักครู่']);
147
+ runningTargets.add(from);
148
+ try {
149
+ const result = await runGatewayAgent({
150
+ platform: 'sms',
151
+ target: from,
152
+ model: opts.model,
153
+ prompt: smsPrompt(params, from, to),
154
+ userText: body,
155
+ budgetUsd: opts.budgetUsd,
156
+ permissionMode: opts.permissionMode ?? 'ask',
157
+ });
158
+ if (result.suppressDelivery)
159
+ return xml(200);
160
+ return xml(200, [result.text || '(ไม่มีผลลัพธ์)']);
161
+ }
162
+ catch (e) {
163
+ opts.onLog?.(`SMS run error (${messageSid || from}): ${redactKey(e.message)}`);
164
+ return xml(200, ['เกิดข้อผิดพลาดภายใน']);
165
+ }
166
+ finally {
167
+ runningTargets.delete(from);
168
+ }
169
+ }