sanook-cli 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +83 -5
  3. package/README.md +240 -23
  4. package/README.th.md +87 -6
  5. package/dist/approval.js +6 -0
  6. package/dist/bin.js +3045 -210
  7. package/dist/brain-context.js +223 -0
  8. package/dist/brain-doctor.js +318 -0
  9. package/dist/brain-eval.js +186 -0
  10. package/dist/brain-final.js +371 -0
  11. package/dist/brain-review.js +382 -0
  12. package/dist/brain.js +12 -1
  13. package/dist/brand.js +1 -1
  14. package/dist/cli-args.js +152 -0
  15. package/dist/cli-option-values.js +16 -0
  16. package/dist/commands.js +172 -13
  17. package/dist/compaction.js +96 -11
  18. package/dist/config.js +118 -28
  19. package/dist/context-compression.js +191 -0
  20. package/dist/cost.js +49 -15
  21. package/dist/first-run.js +21 -0
  22. package/dist/gateway/auth.js +37 -8
  23. package/dist/gateway/bluebubbles.js +205 -0
  24. package/dist/gateway/config.js +929 -0
  25. package/dist/gateway/deliver.js +357 -0
  26. package/dist/gateway/discord.js +124 -0
  27. package/dist/gateway/email.js +472 -0
  28. package/dist/gateway/googlechat.js +207 -0
  29. package/dist/gateway/homeassistant.js +256 -0
  30. package/dist/gateway/ledger.js +18 -0
  31. package/dist/gateway/line.js +171 -0
  32. package/dist/gateway/lock.js +3 -1
  33. package/dist/gateway/matrix.js +366 -0
  34. package/dist/gateway/mattermost.js +322 -0
  35. package/dist/gateway/ntfy.js +218 -0
  36. package/dist/gateway/schedule.js +31 -4
  37. package/dist/gateway/serve.js +267 -7
  38. package/dist/gateway/server.js +253 -19
  39. package/dist/gateway/service.js +224 -0
  40. package/dist/gateway/session.js +343 -0
  41. package/dist/gateway/signal.js +351 -0
  42. package/dist/gateway/slack.js +124 -0
  43. package/dist/gateway/sms.js +169 -0
  44. package/dist/gateway/targets.js +576 -0
  45. package/dist/gateway/teams.js +106 -0
  46. package/dist/gateway/telegram.js +38 -15
  47. package/dist/gateway/webhooks.js +220 -0
  48. package/dist/gateway/whatsapp.js +230 -0
  49. package/dist/hooks.js +13 -2
  50. package/dist/insights-args.js +35 -0
  51. package/dist/insights.js +86 -0
  52. package/dist/loop.js +123 -24
  53. package/dist/lsp/index.js +23 -5
  54. package/dist/mcp-registry.js +350 -0
  55. package/dist/mcp-server.js +1 -1
  56. package/dist/mcp.js +44 -6
  57. package/dist/memory.js +100 -33
  58. package/dist/orchestrate.js +49 -19
  59. package/dist/personality.js +58 -0
  60. package/dist/providers/codex.js +86 -38
  61. package/dist/providers/keys.js +1 -1
  62. package/dist/providers/models.js +22 -6
  63. package/dist/providers/registry.js +38 -49
  64. package/dist/search/chunk.js +7 -8
  65. package/dist/search/cli.js +75 -0
  66. package/dist/search/embed-store.js +3 -0
  67. package/dist/search/indexer.js +44 -1
  68. package/dist/search/store.js +23 -1
  69. package/dist/session.js +93 -7
  70. package/dist/skill-install.js +29 -12
  71. package/dist/support-dump.js +175 -0
  72. package/dist/tools/edit.js +45 -15
  73. package/dist/tools/git.js +10 -5
  74. package/dist/tools/homeassistant.js +106 -0
  75. package/dist/tools/index.js +5 -0
  76. package/dist/tools/list.js +19 -6
  77. package/dist/tools/permission.js +923 -9
  78. package/dist/tools/read.js +16 -4
  79. package/dist/tools/schedule.js +19 -3
  80. package/dist/tools/search.js +217 -13
  81. package/dist/tools/task.js +18 -7
  82. package/dist/tools/timeout.js +21 -3
  83. package/dist/trust.js +11 -1
  84. package/dist/ui/app.js +57 -11
  85. package/dist/ui/brain-wizard.js +2 -2
  86. package/dist/ui/history.js +37 -5
  87. package/dist/ui/mentions.js +3 -2
  88. package/dist/ui/render.js +55 -15
  89. package/dist/ui/setup.js +107 -10
  90. package/dist/update.js +24 -11
  91. package/dist/worktree.js +175 -4
  92. package/package.json +4 -4
  93. package/second-brain/AGENTS.md +6 -4
  94. package/second-brain/CLAUDE.md +7 -1
  95. package/second-brain/Evals/_Index.md +10 -2
  96. package/second-brain/Evals/quality-ledger.md +9 -1
  97. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  98. package/second-brain/GEMINI.md +5 -4
  99. package/second-brain/Home.md +1 -1
  100. package/second-brain/Projects/_Index.md +3 -1
  101. package/second-brain/Projects/sanook-cli/_Index.md +26 -0
  102. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +156 -0
  103. package/second-brain/README.md +1 -1
  104. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  105. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  106. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  107. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  108. package/second-brain/Research/_Index.md +6 -1
  109. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  110. package/second-brain/Reviews/_Index.md +1 -1
  111. package/second-brain/Runbooks/_Index.md +6 -1
  112. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  113. package/second-brain/SANOOK.md +45 -0
  114. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  115. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  116. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  117. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  118. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  119. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  120. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  121. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  122. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  123. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  124. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  125. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  126. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  127. package/second-brain/Sessions/_Index.md +15 -1
  128. package/second-brain/Shared/AI-Context-Index.md +22 -0
  129. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  130. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  131. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  132. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  133. package/second-brain/Shared/Operating-State/current-state.md +22 -3
  134. package/second-brain/Shared/Scripts/_Index.md +3 -1
  135. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  136. package/second-brain/Shared/Tech-Standards/_Index.md +4 -1
  137. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  138. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  139. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  140. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  141. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  142. package/second-brain/Templates/_Index.md +9 -0
  143. package/second-brain/Templates/final-lite.md +111 -0
  144. package/second-brain/Templates/final.md +231 -0
  145. package/second-brain/Vault Structure Map.md +2 -1
  146. package/skills/structured-output-llm/SKILL.md +1 -1
@@ -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,6 +9,14 @@ 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
+ function normalizeOptionalModel(model) {
13
+ const trimmed = model?.trim();
14
+ return trimmed ? trimmed : undefined;
15
+ }
16
+ function normalizeOptionalText(value) {
17
+ const trimmed = value?.trim();
18
+ return trimmed ? trimmed : undefined;
19
+ }
12
20
  // ── low-level: read ตรงจากไฟล์ทุกครั้ง (ไม่ cache snapshot → ไม่มี stale-overwrite) ──
13
21
  async function readTasks() {
14
22
  try {
@@ -49,6 +57,16 @@ export async function dueTasks(now = Date.now()) {
49
57
  // ── mutations (locked, atomic, re-read สด) ──
50
58
  export async function enqueueTask(t) {
51
59
  const task = { id: randomUUID().slice(0, 8), status: 'queued', createdAt: Date.now(), ...t };
60
+ const model = normalizeOptionalModel(t.model);
61
+ const deliver = normalizeOptionalText(t.deliver);
62
+ if (model)
63
+ task.model = model;
64
+ else
65
+ delete task.model;
66
+ if (deliver)
67
+ task.deliver = deliver;
68
+ else
69
+ delete task.deliver;
52
70
  await mutate((tasks) => {
53
71
  tasks.push(task);
54
72
  return { tasks, result: undefined };
@@ -0,0 +1,171 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { redactKey } from '../providers/keys.js';
3
+ import { runGatewayAgent } from './session.js';
4
+ const LINE_PUSH_URL = 'https://api.line.me/v2/bot/message/push';
5
+ const LINE_REPLY_URL = 'https://api.line.me/v2/bot/message/reply';
6
+ const LINE_TEXT_LIMIT = 5000;
7
+ const LINE_PUSH_MESSAGE_LIMIT = 5;
8
+ const runningTargets = new Set();
9
+ export function splitLineText(text) {
10
+ const trimmed = text.trim() || '(ไม่มีผลลัพธ์)';
11
+ const chunks = [];
12
+ for (let i = 0; i < trimmed.length && chunks.length < LINE_PUSH_MESSAGE_LIMIT; i += LINE_TEXT_LIMIT) {
13
+ chunks.push(trimmed.slice(i, i + LINE_TEXT_LIMIT));
14
+ }
15
+ return chunks.length ? chunks : ['(ไม่มีผลลัพธ์)'];
16
+ }
17
+ function lineTextMessages(text) {
18
+ return splitLineText(text).map((chunk) => ({ type: 'text', text: chunk }));
19
+ }
20
+ export async function sendLineMessage(channelAccessToken, to, text) {
21
+ const messages = lineTextMessages(text);
22
+ const r = await fetch(LINE_PUSH_URL, {
23
+ method: 'POST',
24
+ headers: {
25
+ authorization: `Bearer ${channelAccessToken}`,
26
+ 'content-type': 'application/json',
27
+ },
28
+ body: JSON.stringify({ to, messages }),
29
+ });
30
+ if (!r.ok)
31
+ throw new Error(`LINE push message ${r.status}`);
32
+ return { to, messageCount: messages.length };
33
+ }
34
+ export async function replyLineMessage(channelAccessToken, replyToken, text) {
35
+ const messages = lineTextMessages(text);
36
+ const r = await fetch(LINE_REPLY_URL, {
37
+ method: 'POST',
38
+ headers: {
39
+ authorization: `Bearer ${channelAccessToken}`,
40
+ 'content-type': 'application/json',
41
+ },
42
+ body: JSON.stringify({ replyToken, messages }),
43
+ });
44
+ if (!r.ok)
45
+ throw new Error(`LINE reply message ${r.status}`);
46
+ return { to: replyToken, messageCount: messages.length };
47
+ }
48
+ export function verifyLineSignature(channelSecret, rawBody, signature) {
49
+ if (!channelSecret || !signature)
50
+ return false;
51
+ const expected = createHmac('sha256', channelSecret).update(rawBody).digest('base64');
52
+ const a = Buffer.from(signature);
53
+ const b = Buffer.from(expected);
54
+ return a.length === b.length && timingSafeEqual(a, b);
55
+ }
56
+ export function lineSourceTarget(source) {
57
+ if (!source)
58
+ return undefined;
59
+ if (source.type === 'user')
60
+ return source.userId;
61
+ if (source.type === 'group')
62
+ return source.groupId;
63
+ if (source.type === 'room')
64
+ return source.roomId;
65
+ return source.userId ?? source.groupId ?? source.roomId;
66
+ }
67
+ export function isAllowedLineSource(config, source) {
68
+ if (config.allowAllUsers)
69
+ return true;
70
+ const target = lineSourceTarget(source);
71
+ if (!target)
72
+ return false;
73
+ if (target === config.homeChannel)
74
+ return true;
75
+ if (source?.type === 'user')
76
+ return config.allowedUsers.includes(target);
77
+ if (source?.type === 'group')
78
+ return config.allowedGroups.includes(target);
79
+ if (source?.type === 'room')
80
+ return config.allowedRooms.includes(target);
81
+ return [...config.allowedUsers, ...config.allowedGroups, ...config.allowedRooms].includes(target);
82
+ }
83
+ function parseWebhookPayload(rawBody) {
84
+ const parsed = JSON.parse(rawBody);
85
+ if (!parsed || typeof parsed !== 'object')
86
+ return {};
87
+ const payload = parsed;
88
+ return Array.isArray(payload.events) ? payload : {};
89
+ }
90
+ function linePrompt(event, target) {
91
+ const text = event.message?.text?.trim() || '';
92
+ const source = event.source;
93
+ const actor = source?.userId && source.userId !== target ? ` from user ${source.userId}` : '';
94
+ return [`LINE ${source?.type ?? 'unknown'} ${target}${actor}:`, text].join('\n');
95
+ }
96
+ async function replyOrPush(config, event, target, text) {
97
+ if (!config.channelAccessToken)
98
+ throw new Error('LINE channel access token is not configured');
99
+ if (event.replyToken) {
100
+ try {
101
+ await replyLineMessage(config.channelAccessToken, event.replyToken, text);
102
+ return;
103
+ }
104
+ catch {
105
+ // Reply tokens can expire; fall through to Push so long runs can still deliver.
106
+ }
107
+ }
108
+ await sendLineMessage(config.channelAccessToken, target, text);
109
+ }
110
+ export async function handleLineWebhook(opts) {
111
+ if (!opts.config.channelAccessToken || !opts.config.channelSecret) {
112
+ return { status: 503, body: { error: 'line_not_configured' } };
113
+ }
114
+ if (!verifyLineSignature(opts.config.channelSecret, opts.rawBody, opts.signature)) {
115
+ return { status: 401, body: { error: 'invalid_signature' } };
116
+ }
117
+ let payload;
118
+ try {
119
+ payload = parseWebhookPayload(opts.rawBody);
120
+ }
121
+ catch {
122
+ return { status: 400, body: { error: 'invalid_json' } };
123
+ }
124
+ let accepted = 0;
125
+ let ignored = 0;
126
+ for (const event of payload.events ?? []) {
127
+ const target = lineSourceTarget(event.source);
128
+ const text = event.type === 'message' && event.message?.type === 'text' ? event.message.text?.trim() : undefined;
129
+ if (!target || !text) {
130
+ ignored += 1;
131
+ continue;
132
+ }
133
+ if (!isAllowedLineSource(opts.config, event.source)) {
134
+ ignored += 1;
135
+ opts.onLog?.(`LINE: ปฏิเสธ target ${target} (ไม่อยู่ใน allowlist)`);
136
+ if (event.replyToken)
137
+ await replyLineMessage(opts.config.channelAccessToken, event.replyToken, 'ไม่ได้รับอนุญาตให้ใช้ bot นี้').catch(() => { });
138
+ continue;
139
+ }
140
+ if (runningTargets.has(target)) {
141
+ ignored += 1;
142
+ if (event.replyToken)
143
+ await replyLineMessage(opts.config.channelAccessToken, event.replyToken, 'กำลังทำงานก่อนหน้าอยู่ รอสักครู่').catch(() => { });
144
+ continue;
145
+ }
146
+ accepted += 1;
147
+ runningTargets.add(target);
148
+ try {
149
+ const result = await runGatewayAgent({
150
+ platform: 'line',
151
+ target,
152
+ model: opts.model,
153
+ prompt: linePrompt(event, target),
154
+ userText: text,
155
+ budgetUsd: opts.budgetUsd,
156
+ permissionMode: opts.permissionMode ?? 'ask',
157
+ });
158
+ if (!result.suppressDelivery)
159
+ await replyOrPush(opts.config, event, target, result.text || '(ไม่มีผลลัพธ์)');
160
+ }
161
+ catch (e) {
162
+ opts.onLog?.(`LINE run error (${target}): ${redactKey(e.message)}`);
163
+ if (event.replyToken)
164
+ await replyLineMessage(opts.config.channelAccessToken, event.replyToken, 'เกิดข้อผิดพลาดภายใน').catch(() => { });
165
+ }
166
+ finally {
167
+ runningTargets.delete(target);
168
+ }
169
+ }
170
+ return { status: 200, body: { ok: true, accepted, ignored } };
171
+ }
@@ -6,12 +6,14 @@ import { unlinkSync } from 'node:fs';
6
6
  // แคบมาก (ต้องมี 2 mutator พร้อมกัน + holder ตายเป๊ะจังหวะ) ซึ่งแทบเป็นไปไม่ได้ใน workload นี้
7
7
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
8
8
  const LOCK_TTL_MS = 5 * 60_000; // mutate สั้นระดับ ms — lock เก่ากว่านี้ = ค้างแน่ → ยึดได้ (กัน pid-reuse deadlock)
9
+ const LOCK_WRITE_GRACE_MS = 1_000; // open('wx') สร้างไฟล์ก่อนเขียน pid; อย่า evict lock สดที่ยังเขียนไม่จบ
9
10
  /** holder ตายไหม — เช็คจาก pid อย่างเดียว (ใช้กับ singleton ที่ถือยาว, ไม่มี TTL) */
10
11
  async function holderDead(lockPath) {
11
12
  try {
13
+ const st = await stat(lockPath);
12
14
  const pid = parseInt((await readFile(lockPath, 'utf8')).trim(), 10);
13
15
  if (!Number.isInteger(pid) || pid <= 0)
14
- return true; // pid พังยึดได้
16
+ return Date.now() - st.mtimeMs > LOCK_WRITE_GRACE_MS; // pid ยังไม่ถูกเขียน/พังรอ grace สั้นๆ ก่อนยึด
15
17
  try {
16
18
  process.kill(pid, 0); // เช็คว่ามี process นี้ (ไม่ส่ง signal จริง)
17
19
  return false;