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,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
+ }
@@ -0,0 +1,256 @@
1
+ import { BRAND } from '../brand.js';
2
+ import { redactKey } from '../providers/keys.js';
3
+ import { runGatewayAgent } from './session.js';
4
+ const HA_TEXT_LIMIT = 4096;
5
+ const HA_DEFAULT_NOTIFICATION_ID = 'sanook_agent';
6
+ const runningTargets = new Set();
7
+ const lastEventTime = new Map();
8
+ export function normalizeHomeAssistantUrl(raw) {
9
+ const trimmed = raw?.trim().replace(/\/+$/, '');
10
+ if (!trimmed)
11
+ return undefined;
12
+ if (!/^https?:\/\//i.test(trimmed))
13
+ return undefined;
14
+ return trimmed;
15
+ }
16
+ export function homeAssistantApiUrl(config, path) {
17
+ const base = normalizeHomeAssistantUrl(config.url);
18
+ if (!base)
19
+ throw new Error('Home Assistant URL ต้องเป็น URL เช่น http://homeassistant.local:8123');
20
+ return `${base}/api/${path.replace(/^\/+/, '')}`;
21
+ }
22
+ export function homeAssistantWebSocketUrl(url) {
23
+ const base = normalizeHomeAssistantUrl(url);
24
+ if (!base)
25
+ throw new Error('Home Assistant URL ต้องเป็น URL เช่น http://homeassistant.local:8123');
26
+ const parsed = new URL(base);
27
+ parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
28
+ parsed.pathname = `${parsed.pathname.replace(/\/+$/, '')}/api/websocket`;
29
+ parsed.search = '';
30
+ return parsed.toString();
31
+ }
32
+ export function homeAssistantAuthHeaders(token, extra = {}) {
33
+ const clean = token?.trim();
34
+ if (!clean)
35
+ throw new Error('Home Assistant token ว่าง');
36
+ return { authorization: `Bearer ${clean}`, ...extra };
37
+ }
38
+ export function truncateHomeAssistantMessage(raw, limit = HA_TEXT_LIMIT) {
39
+ const text = raw.trim() || '(ไม่มีผลลัพธ์)';
40
+ return text.length <= limit ? text : `${text.slice(0, Math.max(1, limit - 3)).trimEnd()}...`;
41
+ }
42
+ export async function readHomeAssistantJsonResponse(response, label) {
43
+ const text = await response.text().catch(() => '');
44
+ if (!response.ok)
45
+ throw new Error(`${label} ${response.status}${text ? `: ${redactKey(text).slice(0, 200)}` : ''}`);
46
+ if (!text)
47
+ return {};
48
+ try {
49
+ return JSON.parse(text);
50
+ }
51
+ catch {
52
+ throw new Error(`${label} ${response.status}: response ไม่ใช่ JSON: ${redactKey(text).slice(0, 200)}`);
53
+ }
54
+ }
55
+ export async function sendHomeAssistantNotification(config, text, notificationId) {
56
+ const id = notificationId?.trim() || config.homeChannel?.trim() || HA_DEFAULT_NOTIFICATION_ID;
57
+ const r = await fetch(homeAssistantApiUrl(config, '/services/persistent_notification/create'), {
58
+ method: 'POST',
59
+ headers: homeAssistantAuthHeaders(config.token, { 'content-type': 'application/json' }),
60
+ body: JSON.stringify({
61
+ title: BRAND.productName,
62
+ message: truncateHomeAssistantMessage(text),
63
+ notification_id: id,
64
+ }),
65
+ });
66
+ await readHomeAssistantJsonResponse(r, 'Home Assistant persistent_notification.create');
67
+ return { notificationId: id, messageId: `${id}:${Date.now()}`, messageCount: 1 };
68
+ }
69
+ export function homeAssistantDomain(entityId) {
70
+ return entityId.includes('.') ? entityId.split('.')[0] : '';
71
+ }
72
+ export function shouldForwardHomeAssistantEvent(config, event, state = {}) {
73
+ const entityId = event.data?.entity_id?.trim();
74
+ if (!entityId)
75
+ return { ok: false, reason: 'missing_entity' };
76
+ if (config.ignoreEntities.includes(entityId))
77
+ return { ok: false, reason: 'ignored_entity', entityId };
78
+ const domain = homeAssistantDomain(entityId);
79
+ if (config.watchDomains.length || config.watchEntities.length) {
80
+ const domainMatch = config.watchDomains.includes(domain);
81
+ const entityMatch = config.watchEntities.includes(entityId);
82
+ if (!domainMatch && !entityMatch)
83
+ return { ok: false, reason: 'not_watched', entityId };
84
+ }
85
+ else if (!config.watchAll) {
86
+ return { ok: false, reason: 'not_watched', entityId };
87
+ }
88
+ const oldValue = event.data?.old_state?.state ?? 'unknown';
89
+ const newValue = event.data?.new_state?.state ?? 'unknown';
90
+ if (oldValue === newValue)
91
+ return { ok: false, reason: 'unchanged', entityId };
92
+ const seen = state.lastEventTime;
93
+ if (seen) {
94
+ const now = state.nowSeconds ?? Date.now() / 1000;
95
+ const last = seen.get(entityId) ?? 0;
96
+ if (now - last < config.cooldownSeconds)
97
+ return { ok: false, reason: 'cooldown', entityId };
98
+ seen.set(entityId, now);
99
+ }
100
+ return { ok: true, entityId };
101
+ }
102
+ export function formatHomeAssistantStateChange(event) {
103
+ const entityId = event.data?.entity_id?.trim();
104
+ const newState = event.data?.new_state;
105
+ if (!entityId || !newState)
106
+ return undefined;
107
+ const oldValue = event.data?.old_state?.state ?? 'unknown';
108
+ const newValue = newState.state ?? 'unknown';
109
+ if (oldValue === newValue)
110
+ return undefined;
111
+ const attrs = newState.attributes ?? {};
112
+ const friendly = String(attrs.friendly_name ?? entityId);
113
+ const domain = homeAssistantDomain(entityId);
114
+ if (domain === 'climate') {
115
+ const current = attrs.current_temperature ?? '?';
116
+ const target = attrs.temperature ?? '?';
117
+ return `[Home Assistant] ${friendly}: HVAC mode changed from '${oldValue}' to '${newValue}' (current: ${current}, target: ${target})`;
118
+ }
119
+ if (domain === 'sensor') {
120
+ const unit = String(attrs.unit_of_measurement ?? '');
121
+ return `[Home Assistant] ${friendly}: changed from ${oldValue}${unit} to ${newValue}${unit}`;
122
+ }
123
+ if (domain === 'binary_sensor') {
124
+ const oldText = oldValue === 'on' ? 'triggered' : 'cleared';
125
+ const newText = newValue === 'on' ? 'triggered' : 'cleared';
126
+ return `[Home Assistant] ${friendly}: ${newText} (was ${oldText})`;
127
+ }
128
+ if (['light', 'switch', 'fan'].includes(domain)) {
129
+ return `[Home Assistant] ${friendly}: turned ${newValue === 'on' ? 'on' : 'off'}`;
130
+ }
131
+ if (domain === 'alarm_control_panel') {
132
+ return `[Home Assistant] ${friendly}: alarm state changed from '${oldValue}' to '${newValue}'`;
133
+ }
134
+ return `[Home Assistant] ${friendly} (${entityId}): changed from '${oldValue}' to '${newValue}'`;
135
+ }
136
+ export async function handleHomeAssistantEvent(opts) {
137
+ const allowed = shouldForwardHomeAssistantEvent(opts.config, opts.event, {
138
+ lastEventTime: opts.lastEventTime,
139
+ nowSeconds: opts.nowSeconds,
140
+ });
141
+ if (!allowed.ok)
142
+ return { handled: false, reason: allowed.reason };
143
+ const text = formatHomeAssistantStateChange(opts.event);
144
+ if (!text)
145
+ return { handled: false, reason: 'empty_message' };
146
+ const target = opts.config.homeChannel || 'ha_events';
147
+ const running = opts.runningTargets ?? runningTargets;
148
+ if (running.has(target))
149
+ return { handled: false, reason: 'busy' };
150
+ running.add(target);
151
+ try {
152
+ const result = await runGatewayAgent({
153
+ platform: 'homeassistant',
154
+ target,
155
+ model: opts.model,
156
+ prompt: text,
157
+ userText: text,
158
+ budgetUsd: opts.budgetUsd,
159
+ permissionMode: opts.permissionMode ?? 'ask',
160
+ });
161
+ if (!result.suppressDelivery)
162
+ await sendHomeAssistantNotification(opts.config, result.text || '(ไม่มีผลลัพธ์)', target);
163
+ return { handled: true };
164
+ }
165
+ catch (e) {
166
+ opts.onLog?.(`Home Assistant run error (${allowed.entityId ?? 'event'}): ${redactKey(e.message)}`);
167
+ await sendHomeAssistantNotification(opts.config, 'เกิดข้อผิดพลาดภายใน', target).catch(() => { });
168
+ return { handled: false, reason: 'error' };
169
+ }
170
+ finally {
171
+ running.delete(target);
172
+ }
173
+ }
174
+ function defaultWebSocketFactory(url) {
175
+ const WS = globalThis.WebSocket;
176
+ if (!WS)
177
+ throw new Error('WebSocket runtime ไม่พร้อมใช้งานใน Node นี้');
178
+ return new WS(url);
179
+ }
180
+ export function startHomeAssistant(opts) {
181
+ if (!normalizeHomeAssistantUrl(opts.config.url)) {
182
+ opts.onLog?.('Home Assistant ไม่เริ่ม: ต้องตั้ง HASS_URL เช่น http://homeassistant.local:8123');
183
+ return () => { };
184
+ }
185
+ if (!opts.config.token?.trim()) {
186
+ opts.onLog?.('Home Assistant ไม่เริ่ม: ต้องตั้ง HASS_TOKEN');
187
+ return () => { };
188
+ }
189
+ if (!opts.config.watchAll && !opts.config.watchDomains.length && !opts.config.watchEntities.length) {
190
+ opts.onLog?.('Home Assistant: ยังไม่มี watch_domains/watch_entities/watch_all — จะเชื่อมต่อแต่ drop state_changed ทั้งหมด');
191
+ }
192
+ const reconnectMs = opts.reconnectMs ?? 5000;
193
+ const webSocketFactory = opts.webSocketFactory ?? defaultWebSocketFactory;
194
+ let stopped = false;
195
+ let ws;
196
+ let reconnect;
197
+ let subscribeId = 0;
198
+ const connect = () => {
199
+ if (stopped)
200
+ return;
201
+ ws = webSocketFactory(homeAssistantWebSocketUrl(opts.config.url));
202
+ ws.addEventListener('open', () => opts.onLog?.(`Home Assistant: websocket connecting ${opts.config.url}`));
203
+ ws.addEventListener('message', (event) => {
204
+ let msg;
205
+ try {
206
+ msg = JSON.parse(String(event.data ?? '{}'));
207
+ }
208
+ catch {
209
+ return;
210
+ }
211
+ if (msg.type === 'auth_required') {
212
+ ws?.send(JSON.stringify({ type: 'auth', access_token: opts.config.token }));
213
+ return;
214
+ }
215
+ if (msg.type === 'auth_ok') {
216
+ subscribeId += 1;
217
+ ws?.send(JSON.stringify({ id: subscribeId, type: 'subscribe_events', event_type: 'state_changed' }));
218
+ return;
219
+ }
220
+ if (msg.type === 'auth_invalid') {
221
+ opts.onLog?.(`Home Assistant auth failed: ${redactKey(msg.message ?? 'auth_invalid')}`);
222
+ return;
223
+ }
224
+ if (msg.id === subscribeId && msg.success === true) {
225
+ opts.onLog?.('Home Assistant: subscribed to state_changed');
226
+ return;
227
+ }
228
+ if (msg.type === 'event' && msg.event) {
229
+ void handleHomeAssistantEvent({
230
+ config: opts.config,
231
+ event: msg.event,
232
+ model: opts.model,
233
+ budgetUsd: opts.budgetUsd,
234
+ permissionMode: opts.permissionMode,
235
+ runningTargets,
236
+ lastEventTime,
237
+ onLog: opts.onLog,
238
+ });
239
+ }
240
+ });
241
+ ws.addEventListener('close', () => {
242
+ if (stopped)
243
+ return;
244
+ opts.onLog?.(`Home Assistant: websocket closed; reconnecting in ${Math.round(reconnectMs / 1000)}s`);
245
+ reconnect = setTimeout(connect, reconnectMs);
246
+ });
247
+ ws.addEventListener('error', () => opts.onLog?.('Home Assistant: websocket error'));
248
+ };
249
+ connect();
250
+ return () => {
251
+ stopped = true;
252
+ if (reconnect)
253
+ clearTimeout(reconnect);
254
+ ws?.close();
255
+ };
256
+ }
@@ -9,11 +9,38 @@ import { appHomePath } from '../brand.js';
9
9
  const GATEWAY_DIR = appHomePath('gateway');
10
10
  const TASKS_FILE = join(GATEWAY_DIR, 'tasks.json');
11
11
  const LOCK_FILE = join(GATEWAY_DIR, 'tasks.lock');
12
+ const TASK_KINDS = new Set(['cron', 'message', 'once']);
13
+ const TASK_STATUSES = new Set(['queued', 'running', 'done', 'failed']);
14
+ function normalizeOptionalModel(model) {
15
+ const trimmed = model?.trim();
16
+ return trimmed ? trimmed : undefined;
17
+ }
18
+ function normalizeOptionalText(value) {
19
+ const trimmed = value?.trim();
20
+ return trimmed ? trimmed : undefined;
21
+ }
22
+ function isTask(value) {
23
+ if (!value || typeof value !== 'object' || Array.isArray(value))
24
+ return false;
25
+ const task = value;
26
+ return (typeof task.id === 'string' &&
27
+ TASK_KINDS.has(task.kind) &&
28
+ TASK_STATUSES.has(task.status) &&
29
+ typeof task.spec === 'string' &&
30
+ Number.isFinite(task.runAt) &&
31
+ Number.isFinite(task.createdAt) &&
32
+ (task.schedule === undefined || typeof task.schedule === 'string') &&
33
+ (task.model === undefined || typeof task.model === 'string') &&
34
+ (task.deliver === undefined || typeof task.deliver === 'string') &&
35
+ (task.lastRun === undefined || Number.isFinite(task.lastRun)) &&
36
+ (task.lastResult === undefined || typeof task.lastResult === 'string') &&
37
+ (task.lastError === undefined || typeof task.lastError === 'string'));
38
+ }
12
39
  // ── low-level: read ตรงจากไฟล์ทุกครั้ง (ไม่ cache snapshot → ไม่มี stale-overwrite) ──
13
40
  async function readTasks() {
14
41
  try {
15
42
  const parsed = JSON.parse(await readFile(TASKS_FILE, 'utf8'));
16
- return Array.isArray(parsed) ? parsed : [];
43
+ return Array.isArray(parsed) ? parsed.filter(isTask) : [];
17
44
  }
18
45
  catch {
19
46
  return []; // ไม่มีไฟล์/พัง → empty (write แบบ atomic จึงไม่ทำลายของเดิม)
@@ -49,6 +76,16 @@ export async function dueTasks(now = Date.now()) {
49
76
  // ── mutations (locked, atomic, re-read สด) ──
50
77
  export async function enqueueTask(t) {
51
78
  const task = { id: randomUUID().slice(0, 8), status: 'queued', createdAt: Date.now(), ...t };
79
+ const model = normalizeOptionalModel(t.model);
80
+ const deliver = normalizeOptionalText(t.deliver);
81
+ if (model)
82
+ task.model = model;
83
+ else
84
+ delete task.model;
85
+ if (deliver)
86
+ task.deliver = deliver;
87
+ else
88
+ delete task.deliver;
52
89
  await mutate((tasks) => {
53
90
  tasks.push(task);
54
91
  return { tasks, result: undefined };