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,322 @@
1
+ import { BRAND } from '../brand.js';
2
+ import { redactKey } from '../providers/keys.js';
3
+ import { runGatewayAgent } from './session.js';
4
+ const MATTERMOST_TEXT_LIMIT = 4000;
5
+ const runningTargets = new Set();
6
+ export function normalizeMattermostUrl(raw) {
7
+ const trimmed = raw?.trim().replace(/\/+$/, '');
8
+ if (!trimmed)
9
+ return undefined;
10
+ if (!/^https?:\/\//i.test(trimmed))
11
+ return undefined;
12
+ return trimmed;
13
+ }
14
+ export function mattermostApiUrl(config, path, params) {
15
+ const base = normalizeMattermostUrl(config.serverUrl);
16
+ if (!base)
17
+ throw new Error('Mattermost URL ต้องเป็น URL เช่น https://mm.example.com');
18
+ const url = new URL(`${base}/api/v4/${path.replace(/^\/+/, '')}`);
19
+ for (const [key, value] of Object.entries(params ?? {})) {
20
+ if (value != null && String(value).trim())
21
+ url.searchParams.set(key, String(value));
22
+ }
23
+ return url.toString();
24
+ }
25
+ export function mattermostWebSocketUrl(serverUrl) {
26
+ const base = normalizeMattermostUrl(serverUrl);
27
+ if (!base)
28
+ throw new Error('Mattermost URL ต้องเป็น URL เช่น https://mm.example.com');
29
+ const url = new URL(base);
30
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
31
+ const pathBase = url.pathname.replace(/\/+$/, '');
32
+ url.pathname = `${pathBase}/api/v4/websocket`;
33
+ url.search = '';
34
+ return url.toString();
35
+ }
36
+ export function mattermostAuthHeaders(token, extra = {}) {
37
+ const clean = token?.trim();
38
+ if (!clean)
39
+ throw new Error('Mattermost token ว่าง');
40
+ return { authorization: `Bearer ${clean}`, ...extra };
41
+ }
42
+ export function splitMattermostText(raw, limit = MATTERMOST_TEXT_LIMIT) {
43
+ let remaining = raw.trim() || '(ไม่มีผลลัพธ์)';
44
+ const chunks = [];
45
+ while (remaining.length > limit) {
46
+ const window = remaining.slice(0, limit + 1);
47
+ let cut = window.lastIndexOf('\n');
48
+ if (cut < Math.floor(limit * 0.4))
49
+ cut = window.lastIndexOf(' ');
50
+ if (cut < Math.floor(limit * 0.4))
51
+ cut = limit;
52
+ chunks.push(remaining.slice(0, cut).trim());
53
+ remaining = remaining.slice(cut).trimStart();
54
+ }
55
+ if (remaining)
56
+ chunks.push(remaining);
57
+ return chunks.length ? chunks : ['(ไม่มีผลลัพธ์)'];
58
+ }
59
+ async function readJsonOrThrow(response, label) {
60
+ const text = await response.text().catch(() => '');
61
+ if (!response.ok)
62
+ throw new Error(`${label} ${response.status}${text ? `: ${redactKey(text).slice(0, 200)}` : ''}`);
63
+ if (!text)
64
+ return {};
65
+ try {
66
+ return JSON.parse(text);
67
+ }
68
+ catch {
69
+ throw new Error(`${label} ${response.status}: response ไม่ใช่ JSON: ${redactKey(text).slice(0, 200)}`);
70
+ }
71
+ }
72
+ export async function mattermostMe(config) {
73
+ const r = await fetch(mattermostApiUrl(config, '/users/me'), {
74
+ method: 'GET',
75
+ headers: mattermostAuthHeaders(config.token),
76
+ });
77
+ return readJsonOrThrow(r, 'Mattermost users/me');
78
+ }
79
+ export async function sendMattermostMessage(config, channelId, text, rootId) {
80
+ const channel = channelId.trim();
81
+ if (!channel)
82
+ throw new Error('Mattermost channel id ว่าง');
83
+ const chunks = splitMattermostText(text);
84
+ const postIds = [];
85
+ for (const chunk of chunks) {
86
+ const r = await fetch(mattermostApiUrl(config, '/posts'), {
87
+ method: 'POST',
88
+ headers: mattermostAuthHeaders(config.token, { 'content-type': 'application/json' }),
89
+ body: JSON.stringify({
90
+ channel_id: channel,
91
+ message: chunk,
92
+ ...(rootId?.trim() ? { root_id: rootId.trim() } : {}),
93
+ }),
94
+ });
95
+ const parsed = await readJsonOrThrow(r, 'Mattermost create post');
96
+ if (parsed.id)
97
+ postIds.push(parsed.id);
98
+ }
99
+ return { channelId: channel, postIds, messageCount: chunks.length };
100
+ }
101
+ function parsePost(raw) {
102
+ if (!raw)
103
+ return undefined;
104
+ if (typeof raw === 'string') {
105
+ try {
106
+ const parsed = JSON.parse(raw);
107
+ return parsed && typeof parsed === 'object' ? parsed : undefined;
108
+ }
109
+ catch {
110
+ return undefined;
111
+ }
112
+ }
113
+ return typeof raw === 'object' ? raw : undefined;
114
+ }
115
+ function parseMentions(raw) {
116
+ if (!raw)
117
+ return [];
118
+ if (Array.isArray(raw))
119
+ return raw.filter((v) => typeof v === 'string');
120
+ if (typeof raw !== 'string')
121
+ return [];
122
+ try {
123
+ const parsed = JSON.parse(raw);
124
+ if (Array.isArray(parsed))
125
+ return parsed.filter((v) => typeof v === 'string');
126
+ }
127
+ catch {
128
+ // Some Mattermost-compatible gateways emit comma-separated mention IDs.
129
+ }
130
+ return raw
131
+ .split(',')
132
+ .map((s) => s.trim())
133
+ .filter(Boolean);
134
+ }
135
+ export function mattermostMentionsBot(text, envelope, botUsername, botUserId) {
136
+ const mentions = parseMentions(envelope.data?.mentions);
137
+ if (botUserId && mentions.includes(botUserId))
138
+ return true;
139
+ const username = botUsername?.trim().replace(/^@/, '');
140
+ if (!username)
141
+ return false;
142
+ return new RegExp(`(^|\\s)@${escapeRegExp(username)}(\\b|\\s|:)`, 'i').test(text);
143
+ }
144
+ export function parseMattermostPostedEvent(raw, bot = {}) {
145
+ let envelope;
146
+ try {
147
+ envelope = JSON.parse(raw);
148
+ }
149
+ catch {
150
+ return null;
151
+ }
152
+ if (envelope.event !== 'posted')
153
+ return null;
154
+ const post = parsePost(envelope.data?.post);
155
+ const userId = post?.user_id?.trim();
156
+ const channelId = (post?.channel_id ?? envelope.broadcast?.channel_id)?.trim();
157
+ const text = post?.message?.trim();
158
+ if (!userId || !channelId || !text)
159
+ return null;
160
+ if (bot.userId && userId === bot.userId)
161
+ return null;
162
+ const channelType = envelope.data?.channel_type?.trim();
163
+ return {
164
+ channelId,
165
+ userId,
166
+ text,
167
+ postId: post?.id,
168
+ rootId: post?.root_id || undefined,
169
+ channelType,
170
+ createAt: post?.create_at,
171
+ isDirect: channelType === 'D',
172
+ mentionsBot: mattermostMentionsBot(text, envelope, bot.username, bot.userId),
173
+ };
174
+ }
175
+ export function isAllowedMattermostEvent(config, event) {
176
+ if (!event.isDirect && config.allowedChannels.length && !config.allowedChannels.includes(event.channelId))
177
+ return false;
178
+ if (config.allowAllUsers)
179
+ return true;
180
+ return config.allowedUsers.includes(event.userId);
181
+ }
182
+ export function mattermostShouldRespond(config, event) {
183
+ if (event.isDirect)
184
+ return true;
185
+ if (config.freeResponseChannels.includes(event.channelId))
186
+ return true;
187
+ if (!config.requireMention)
188
+ return true;
189
+ return event.mentionsBot;
190
+ }
191
+ export function mattermostUserText(event, botUsername) {
192
+ let text = event.text.replace(/^!(new|reset|status|help)\b/i, '/$1').trim();
193
+ const username = botUsername?.trim().replace(/^@/, '');
194
+ if (username)
195
+ text = text.replace(new RegExp(`(^|\\s)@${escapeRegExp(username)}(\\b|\\s|:)`, 'gi'), ' ').trim();
196
+ return text || event.text;
197
+ }
198
+ function mattermostPrompt(event) {
199
+ return [`Mattermost ${event.isDirect ? 'DM' : 'channel'} ${event.channelId} from ${event.userId}:`, event.text].join('\n');
200
+ }
201
+ function mattermostReplyRootId(config, event) {
202
+ return event.rootId || (config.replyMode === 'thread' ? event.postId : undefined);
203
+ }
204
+ function mattermostSessionTarget(config, event) {
205
+ if (event.isDirect)
206
+ return event.channelId;
207
+ const threadId = event.rootId || (config.replyMode === 'thread' ? event.postId : undefined);
208
+ if (threadId)
209
+ return config.groupSessionsPerUser ? `${event.channelId}:${threadId}:${event.userId}` : `${event.channelId}:${threadId}`;
210
+ return config.groupSessionsPerUser ? `${event.channelId}:${event.userId}` : event.channelId;
211
+ }
212
+ export async function handleMattermostEvent(opts) {
213
+ const event = opts.event;
214
+ if (!isAllowedMattermostEvent(opts.config, event)) {
215
+ opts.onLog?.(`Mattermost: ปฏิเสธ ${event.userId} ใน ${event.channelId} (ไม่อยู่ใน allowlist)`);
216
+ return { handled: false, reason: 'not_allowed' };
217
+ }
218
+ if (!mattermostShouldRespond(opts.config, event))
219
+ return { handled: false, reason: 'not_mentioned' };
220
+ const target = mattermostSessionTarget(opts.config, event);
221
+ const running = opts.runningTargets ?? runningTargets;
222
+ if (running.has(target))
223
+ return { handled: false, reason: 'busy' };
224
+ running.add(target);
225
+ try {
226
+ const result = await runGatewayAgent({
227
+ platform: 'mattermost',
228
+ target,
229
+ model: opts.model,
230
+ prompt: mattermostPrompt(event),
231
+ userText: mattermostUserText(event, opts.botUsername),
232
+ budgetUsd: opts.budgetUsd,
233
+ permissionMode: opts.permissionMode ?? 'ask',
234
+ });
235
+ if (!result.suppressDelivery) {
236
+ await sendMattermostMessage(opts.config, event.channelId, result.text || '(ไม่มีผลลัพธ์)', mattermostReplyRootId(opts.config, event));
237
+ }
238
+ return { handled: true };
239
+ }
240
+ catch (e) {
241
+ opts.onLog?.(`Mattermost run error (${event.channelId}): ${redactKey(e.message)}`);
242
+ await sendMattermostMessage(opts.config, event.channelId, 'เกิดข้อผิดพลาดภายใน', mattermostReplyRootId(opts.config, event)).catch(() => { });
243
+ return { handled: false, reason: 'error' };
244
+ }
245
+ finally {
246
+ running.delete(target);
247
+ }
248
+ }
249
+ function defaultWebSocketFactory(url) {
250
+ const WS = globalThis.WebSocket;
251
+ if (!WS)
252
+ throw new Error('WebSocket runtime ไม่พร้อมใช้งานใน Node นี้');
253
+ return new WS(url);
254
+ }
255
+ export async function startMattermost(opts) {
256
+ if (!normalizeMattermostUrl(opts.config.serverUrl)) {
257
+ opts.onLog?.('Mattermost ไม่เริ่ม: ต้องตั้ง MATTERMOST_URL เช่น https://mm.example.com');
258
+ return () => { };
259
+ }
260
+ if (!opts.config.token?.trim()) {
261
+ opts.onLog?.('Mattermost ไม่เริ่ม: ต้องตั้ง MATTERMOST_TOKEN');
262
+ return () => { };
263
+ }
264
+ if (!opts.config.allowAllUsers && !opts.config.allowedUsers.length) {
265
+ opts.onLog?.('Mattermost ไม่เริ่ม: ต้องตั้ง MATTERMOST_ALLOWED_USERS เพื่อ fail-closed');
266
+ return () => { };
267
+ }
268
+ const me = await mattermostMe(opts.config);
269
+ if (!me.id)
270
+ throw new Error('Mattermost users/me response ไม่มี id');
271
+ const ws = (opts.webSocketFactory ?? defaultWebSocketFactory)(mattermostWebSocketUrl(opts.config.serverUrl));
272
+ const bot = { userId: me.id, username: me.username };
273
+ let stopped = false;
274
+ ws.addEventListener('open', () => {
275
+ opts.onLog?.(`Mattermost: websocket connecting as @${me.username ?? me.id}`);
276
+ ws.send(JSON.stringify({
277
+ seq: 1,
278
+ action: 'authentication_challenge',
279
+ data: { token: opts.config.token },
280
+ }));
281
+ });
282
+ ws.addEventListener('message', (event) => {
283
+ const raw = String(event.data ?? '');
284
+ if (!raw)
285
+ return;
286
+ try {
287
+ const envelope = JSON.parse(raw);
288
+ if (envelope.status === 'OK' && envelope.seq_reply === 1) {
289
+ opts.onLog?.('Mattermost: websocket authenticated');
290
+ return;
291
+ }
292
+ }
293
+ catch {
294
+ return;
295
+ }
296
+ const inbound = parseMattermostPostedEvent(raw, bot);
297
+ if (!inbound)
298
+ return;
299
+ void handleMattermostEvent({
300
+ config: opts.config,
301
+ event: inbound,
302
+ model: opts.model,
303
+ budgetUsd: opts.budgetUsd,
304
+ permissionMode: opts.permissionMode,
305
+ botUsername: bot.username,
306
+ runningTargets,
307
+ onLog: opts.onLog,
308
+ });
309
+ });
310
+ ws.addEventListener('close', () => {
311
+ if (!stopped)
312
+ opts.onLog?.('Mattermost: websocket closed');
313
+ });
314
+ ws.addEventListener('error', () => opts.onLog?.('Mattermost: websocket error'));
315
+ return () => {
316
+ stopped = true;
317
+ ws.close();
318
+ };
319
+ }
320
+ function escapeRegExp(value) {
321
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
322
+ }
@@ -0,0 +1,218 @@
1
+ import { BRAND } from '../brand.js';
2
+ import { redactKey } from '../providers/keys.js';
3
+ import { runGatewayAgent } from './session.js';
4
+ const NTFY_MESSAGE_LIMIT_BYTES = 4096;
5
+ const NTFY_REPLY_TITLE = BRAND.productName;
6
+ const runningTargets = new Set();
7
+ function truthyHeader(value) {
8
+ return value === 'true' || value === '1' || value === 'yes';
9
+ }
10
+ export function ntfyAuthHeader(token) {
11
+ const trimmed = token?.trim();
12
+ if (!trimmed)
13
+ return undefined;
14
+ if (trimmed.includes(':'))
15
+ return `Basic ${Buffer.from(trimmed, 'utf8').toString('base64')}`;
16
+ return `Bearer ${trimmed}`;
17
+ }
18
+ export function ntfyTopicUrl(serverUrl, topic, suffix = '') {
19
+ const base = (serverUrl || 'https://ntfy.sh').replace(/\/+$/, '');
20
+ return `${base}/${encodeURIComponent(topic.trim())}${suffix}`;
21
+ }
22
+ export function truncateNtfyMessage(raw, limitBytes = NTFY_MESSAGE_LIMIT_BYTES) {
23
+ const text = raw.trim() || '(ไม่มีผลลัพธ์)';
24
+ if (Buffer.byteLength(text, 'utf8') <= limitBytes)
25
+ return { text, truncated: false };
26
+ const suffix = '...';
27
+ const budget = Math.max(1, limitBytes - Buffer.byteLength(suffix, 'utf8'));
28
+ let out = '';
29
+ let used = 0;
30
+ for (const ch of text) {
31
+ const next = Buffer.byteLength(ch, 'utf8');
32
+ if (used + next > budget)
33
+ break;
34
+ out += ch;
35
+ used += next;
36
+ }
37
+ return { text: `${out.trimEnd()}${suffix}`, truncated: true };
38
+ }
39
+ export async function sendNtfyMessage(config, topic, text, options = {}) {
40
+ const targetTopic = topic.trim();
41
+ if (!targetTopic)
42
+ throw new Error('ntfy topic ว่าง');
43
+ const body = truncateNtfyMessage(text);
44
+ const headers = {
45
+ 'content-type': options.markdown ?? config.markdown ? 'text/markdown; charset=utf-8' : 'text/plain; charset=utf-8',
46
+ title: options.title?.trim() || NTFY_REPLY_TITLE,
47
+ };
48
+ const auth = ntfyAuthHeader(config.token);
49
+ if (auth)
50
+ headers.authorization = auth;
51
+ if (options.markdown ?? config.markdown)
52
+ headers.markdown = 'yes';
53
+ const r = await fetch(ntfyTopicUrl(config.serverUrl, targetTopic), {
54
+ method: 'POST',
55
+ headers,
56
+ body: body.text,
57
+ });
58
+ if (!r.ok) {
59
+ const detail = await r.text().catch(() => '');
60
+ throw new Error(`ntfy publish ${r.status}${detail ? `: ${redactKey(detail).slice(0, 200)}` : ''}`);
61
+ }
62
+ const parsed = (await r.json().catch(() => ({})));
63
+ return { topic: targetTopic, messageId: parsed.id, messageCount: 1, truncated: body.truncated };
64
+ }
65
+ export function parseNtfyJsonLine(line) {
66
+ const trimmed = line.trim();
67
+ if (!trimmed)
68
+ return null;
69
+ const parsed = JSON.parse(trimmed);
70
+ if (!parsed || typeof parsed !== 'object')
71
+ return null;
72
+ const event = parsed;
73
+ if (event.event !== 'message')
74
+ return null;
75
+ if (typeof event.message !== 'string' || !event.message.trim())
76
+ return null;
77
+ return event;
78
+ }
79
+ export function isAllowedNtfyTopic(config, topic) {
80
+ if (config.allowAllUsers)
81
+ return true;
82
+ const target = topic?.trim();
83
+ if (!target)
84
+ return false;
85
+ const allowed = new Set([config.topic, config.homeChannel, ...config.allowedUsers].filter((v) => Boolean(v?.trim())));
86
+ return allowed.has(target);
87
+ }
88
+ function ntfyPrompt(event, topic) {
89
+ const parts = [`ntfy topic ${topic}:`, event.message?.trim() || '(empty)'];
90
+ if (event.title?.trim() && event.title !== NTFY_REPLY_TITLE)
91
+ parts.splice(1, 0, `title: ${event.title.trim()}`);
92
+ return parts.join('\n');
93
+ }
94
+ export async function handleNtfyEvent(opts) {
95
+ const event = opts.event;
96
+ if (event.event !== 'message' || !event.message?.trim())
97
+ return { handled: false, reason: 'ignored_event' };
98
+ if (event.title === NTFY_REPLY_TITLE || truthyHeader(String(event.sanookReply ?? ''))) {
99
+ return { handled: false, reason: 'self_message' };
100
+ }
101
+ const topic = event.topic?.trim() || opts.config.topic?.trim();
102
+ if (!isAllowedNtfyTopic(opts.config, topic)) {
103
+ opts.onLog?.(`ntfy: ปฏิเสธ topic ${topic ?? '(unknown)'} (ไม่อยู่ใน allowlist)`);
104
+ return { handled: false, reason: 'not_allowed' };
105
+ }
106
+ if (!topic)
107
+ return { handled: false, reason: 'missing_topic' };
108
+ const running = opts.runningTargets ?? runningTargets;
109
+ if (running.has(topic))
110
+ return { handled: false, reason: 'busy' };
111
+ running.add(topic);
112
+ try {
113
+ const result = await runGatewayAgent({
114
+ platform: 'ntfy',
115
+ target: topic,
116
+ model: opts.model,
117
+ prompt: ntfyPrompt(event, topic),
118
+ userText: event.message.trim(),
119
+ budgetUsd: opts.budgetUsd,
120
+ permissionMode: opts.permissionMode ?? 'ask',
121
+ });
122
+ if (!result.suppressDelivery) {
123
+ await sendNtfyMessage(opts.config, opts.config.publishTopic || topic, result.text || '(ไม่มีผลลัพธ์)', { title: NTFY_REPLY_TITLE });
124
+ }
125
+ return { handled: true };
126
+ }
127
+ catch (e) {
128
+ opts.onLog?.(`ntfy run error (${topic}): ${redactKey(e.message)}`);
129
+ await sendNtfyMessage(opts.config, opts.config.publishTopic || topic, 'เกิดข้อผิดพลาดภายใน', { title: NTFY_REPLY_TITLE }).catch(() => { });
130
+ return { handled: false, reason: 'error' };
131
+ }
132
+ finally {
133
+ running.delete(topic);
134
+ }
135
+ }
136
+ async function delay(ms, signal) {
137
+ if (signal.aborted)
138
+ return;
139
+ await new Promise((resolve) => {
140
+ const timer = setTimeout(resolve, ms);
141
+ signal.addEventListener('abort', () => {
142
+ clearTimeout(timer);
143
+ resolve();
144
+ }, { once: true });
145
+ });
146
+ }
147
+ async function readNtfyStream(response, opts, signal) {
148
+ if (!response.body)
149
+ throw new Error('ntfy stream ไม่มี response body');
150
+ const reader = response.body.getReader();
151
+ const decoder = new TextDecoder();
152
+ let pending = '';
153
+ for (;;) {
154
+ const { done, value } = await reader.read();
155
+ pending += decoder.decode(value, { stream: !done });
156
+ const lines = pending.split(/\r?\n/);
157
+ pending = lines.pop() ?? '';
158
+ for (const line of lines) {
159
+ if (signal.aborted)
160
+ return;
161
+ try {
162
+ const event = parseNtfyJsonLine(line);
163
+ if (event)
164
+ await handleNtfyEvent({ ...opts, event, runningTargets });
165
+ }
166
+ catch (e) {
167
+ opts.onLog?.(`ntfy parse error: ${redactKey(e.message)}`);
168
+ }
169
+ }
170
+ if (done)
171
+ break;
172
+ }
173
+ const last = pending.trim();
174
+ if (last && !signal.aborted) {
175
+ const event = parseNtfyJsonLine(last);
176
+ if (event)
177
+ await handleNtfyEvent({ ...opts, event, runningTargets });
178
+ }
179
+ }
180
+ export function startNtfy(opts) {
181
+ const topic = opts.config.topic?.trim();
182
+ if (!topic) {
183
+ opts.onLog?.('ntfy ไม่เริ่ม: ต้องตั้ง NTFY_TOPIC หรือ gateway setup ntfy --topic');
184
+ return () => { };
185
+ }
186
+ if (!isAllowedNtfyTopic(opts.config, topic)) {
187
+ opts.onLog?.('ntfy ไม่เริ่ม: ต้องตั้ง NTFY_ALLOWED_USERS ให้รวม topic หรือระบุ --allow-all-users เพื่อ fail-closed');
188
+ return () => { };
189
+ }
190
+ const controller = new AbortController();
191
+ const reconnectMs = opts.reconnectMs ?? 5000;
192
+ const headers = { accept: 'application/x-ndjson' };
193
+ const auth = ntfyAuthHeader(opts.config.token);
194
+ if (auth)
195
+ headers.authorization = auth;
196
+ const loop = async () => {
197
+ opts.onLog?.(`ntfy: subscribe ${ntfyTopicUrl(opts.config.serverUrl, topic, '/json?since=1s')}`);
198
+ while (!controller.signal.aborted) {
199
+ try {
200
+ const r = await fetch(ntfyTopicUrl(opts.config.serverUrl, topic, '/json?since=1s'), {
201
+ method: 'GET',
202
+ headers,
203
+ signal: controller.signal,
204
+ });
205
+ if (!r.ok)
206
+ throw new Error(`ntfy subscribe ${r.status}`);
207
+ await readNtfyStream(r, opts, controller.signal);
208
+ }
209
+ catch (e) {
210
+ if (!controller.signal.aborted)
211
+ opts.onLog?.(`ntfy stream error: ${redactKey(e.message)}; reconnecting`);
212
+ }
213
+ await delay(reconnectMs, controller.signal);
214
+ }
215
+ };
216
+ void loop();
217
+ return () => controller.abort();
218
+ }
@@ -2,7 +2,23 @@
2
2
  // support: interval ("every 30m" / "2h"), daily ("09:00"), ISO timestamp (one-shot), "now"
3
3
  // pure — รับ now เป็น param (ไม่เรียก Date.now ใน body หลัก) เพื่อ test ได้
4
4
  const UNIT_MS = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
5
+ const MAX_DATE_MS = 8_640_000_000_000_000;
5
6
  const pad = (n) => String(n).padStart(2, '0');
7
+ function isValidEpochMs(value) {
8
+ return Number.isSafeInteger(value) && Math.abs(value) <= MAX_DATE_MS;
9
+ }
10
+ function isLeapYear(year) {
11
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
12
+ }
13
+ function isValidCalendarDate(year, month, day) {
14
+ if (month < 1 || month > 12 || day < 1)
15
+ return false;
16
+ const daysInMonth = [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
17
+ return day <= daysInMonth[month - 1];
18
+ }
19
+ function isValidClockTime(hour, minute) {
20
+ return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59;
21
+ }
6
22
  /** next occurrence ของ HH:MM (local time) หลัง now */
7
23
  function nextDaily(minutesOfDay, now) {
8
24
  const target = new Date(now);
@@ -15,6 +31,8 @@ export function parseSchedule(input, now) {
15
31
  const s = input.trim().toLowerCase();
16
32
  if (!s)
17
33
  return null;
34
+ if (!isValidEpochMs(now))
35
+ return null;
18
36
  if (s === 'now' || s === 'immediately') {
19
37
  return { runAt: now, recurring: false, kind: 'once', normalized: 'now' };
20
38
  }
@@ -24,10 +42,11 @@ export function parseSchedule(input, now) {
24
42
  const n = parseInt(iv[1], 10);
25
43
  const unit = iv[2][0]; // s/m/h/d (ตัวแรกพอ)
26
44
  const ms = n * (UNIT_MS[unit] ?? 0);
45
+ const runAt = now + ms;
27
46
  // กัน overflow → runAt เป็น Invalid Date ที่ due() ไม่มีวันยิง
28
- if (!Number.isSafeInteger(ms) || ms <= 0 || !Number.isFinite(now + ms))
47
+ if (!Number.isSafeInteger(ms) || ms <= 0 || !isValidEpochMs(runAt))
29
48
  return null;
30
- return { runAt: now + ms, recurring: true, kind: 'cron', normalized: `every ${n}${unit}` };
49
+ return { runAt, recurring: true, kind: 'cron', normalized: `every ${n}${unit}` };
31
50
  }
32
51
  // daily time: "09:00" | "at 9:00" | "daily 09:30"
33
52
  const dt = s.match(/^(?:at\s+|daily\s+(?:at\s+)?)?(\d{1,2}):(\d{2})$/);
@@ -37,7 +56,10 @@ export function parseSchedule(input, now) {
37
56
  if (hh > 23 || mm > 59)
38
57
  return null;
39
58
  const mins = hh * 60 + mm;
40
- return { runAt: nextDaily(mins, now), recurring: true, kind: 'cron', normalized: `${pad(hh)}:${pad(mm)}` };
59
+ const runAt = nextDaily(mins, now);
60
+ if (!isValidEpochMs(runAt))
61
+ return null;
62
+ return { runAt, recurring: true, kind: 'cron', normalized: `${pad(hh)}:${pad(mm)}` };
41
63
  }
42
64
  // ── NL ภาษาไทย / aliases → map เป็น canonical แล้ว parse ซ้ำ ──
43
65
  if (/^(ทุก\s*ๆ?\s*)?(ชั่วโมง|ชม\.?|hourly)$/.test(s))
@@ -57,7 +79,12 @@ export function parseSchedule(input, now) {
57
79
  return parseSchedule(`${thDaily[1]}:${thDaily[2]}`, now);
58
80
  // ISO timestamp (one-shot) — รับเฉพาะรูปแบบที่มี date จริง (กัน Date.parse รับ bare number/year-only กำกวม)
59
81
  const raw = input.trim();
60
- if (/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}|$)/.test(raw)) {
82
+ const iso = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})|$)/);
83
+ if (iso) {
84
+ if (!isValidCalendarDate(Number(iso[1]), Number(iso[2]), Number(iso[3])))
85
+ return null;
86
+ if (iso[4] !== undefined && !isValidClockTime(Number(iso[4]), Number(iso[5])))
87
+ return null;
61
88
  const t = Date.parse(raw);
62
89
  if (!Number.isNaN(t)) {
63
90
  if (t < now)