sanook-cli 0.5.2 → 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 (119) hide show
  1. package/CHANGELOG.md +91 -2
  2. package/README.md +15 -3
  3. package/README.th.md +8 -1
  4. package/dist/approval.js +7 -0
  5. package/dist/bin.js +623 -56
  6. package/dist/brain-consolidate.js +335 -0
  7. package/dist/brain-context.js +42 -3
  8. package/dist/brain-final.js +15 -9
  9. package/dist/brain-metrics.js +277 -0
  10. package/dist/brain-new.js +402 -0
  11. package/dist/brain-pack.js +210 -0
  12. package/dist/brain-repair.js +280 -0
  13. package/dist/brain.js +3 -0
  14. package/dist/cli-args.js +47 -9
  15. package/dist/cli-option-values.js +1 -1
  16. package/dist/clipboard.js +65 -0
  17. package/dist/commands.js +94 -14
  18. package/dist/config.js +31 -5
  19. package/dist/context-pack.js +145 -0
  20. package/dist/dashboard/api-helpers.js +87 -0
  21. package/dist/dashboard/server.js +179 -0
  22. package/dist/dashboard/static/app.js +277 -0
  23. package/dist/dashboard/static/index.html +39 -0
  24. package/dist/dashboard/static/styles.css +85 -0
  25. package/dist/diff.js +10 -2
  26. package/dist/gateway/auth.js +14 -3
  27. package/dist/gateway/deliver.js +45 -3
  28. package/dist/gateway/doctor.js +456 -0
  29. package/dist/gateway/email.js +30 -1
  30. package/dist/gateway/ledger.js +20 -1
  31. package/dist/gateway/session.js +30 -11
  32. package/dist/hotkeys.js +21 -0
  33. package/dist/i18n/en.js +98 -0
  34. package/dist/i18n/index.js +19 -0
  35. package/dist/i18n/th.js +98 -0
  36. package/dist/i18n/types.js +1 -0
  37. package/dist/insights-args.js +24 -4
  38. package/dist/knowledge.js +55 -29
  39. package/dist/loop.js +34 -5
  40. package/dist/mcp-hub.js +33 -0
  41. package/dist/mcp-registry.js +153 -9
  42. package/dist/mcp-risk.js +71 -0
  43. package/dist/mcp.js +77 -5
  44. package/dist/memory-log.js +90 -0
  45. package/dist/memory-store.js +37 -1
  46. package/dist/memory.js +51 -7
  47. package/dist/model-picker.js +58 -0
  48. package/dist/orchestrate.js +7 -5
  49. package/dist/plan-handoff.js +17 -0
  50. package/dist/polyglot.js +162 -0
  51. package/dist/process-runner.js +96 -0
  52. package/dist/project-init.js +91 -0
  53. package/dist/project-registry.js +143 -0
  54. package/dist/project-scaffold.js +124 -0
  55. package/dist/prompt-size.js +155 -0
  56. package/dist/providers/codex-login.js +138 -0
  57. package/dist/providers/codex.js +20 -8
  58. package/dist/providers/keys.js +21 -0
  59. package/dist/providers/models.js +1 -1
  60. package/dist/search/cli.js +9 -1
  61. package/dist/search/embedding-config.js +22 -0
  62. package/dist/search/engine.js +2 -13
  63. package/dist/search/indexer.js +10 -10
  64. package/dist/session-distill.js +84 -0
  65. package/dist/session.js +1 -11
  66. package/dist/skill-install.js +24 -1
  67. package/dist/skills.js +33 -0
  68. package/dist/slash-completion.js +155 -0
  69. package/dist/support-dump.js +31 -0
  70. package/dist/tool-catalog.js +59 -0
  71. package/dist/tools/index.js +5 -0
  72. package/dist/tools/permission.js +82 -16
  73. package/dist/tools/polyglot.js +126 -0
  74. package/dist/tools/sandbox.js +38 -13
  75. package/dist/tools/search.js +9 -2
  76. package/dist/tools/task.js +22 -2
  77. package/dist/tools/timeout.js +7 -5
  78. package/dist/tools/web-fetch-tool.js +33 -0
  79. package/dist/turn-retrieval.js +83 -0
  80. package/dist/ui/app.js +835 -29
  81. package/dist/ui/banner.js +78 -4
  82. package/dist/ui/markdown.js +122 -0
  83. package/dist/ui/overlay.js +496 -0
  84. package/dist/ui/queue.js +23 -0
  85. package/dist/ui/render.js +20 -1
  86. package/dist/ui/session-panel.js +115 -0
  87. package/dist/ui/setup-providers.js +40 -0
  88. package/dist/ui/setup.js +163 -50
  89. package/dist/ui/status.js +142 -0
  90. package/dist/ui/thinking-panel.js +36 -0
  91. package/dist/ui/tool-trail.js +97 -0
  92. package/dist/ui/transcript.js +26 -0
  93. package/dist/ui/useBusyElapsed.js +19 -0
  94. package/dist/ui/useEditor.js +144 -5
  95. package/dist/ui/useGitBranch.js +57 -0
  96. package/dist/update.js +32 -6
  97. package/dist/web-fetch.js +637 -0
  98. package/dist/web-surface.js +190 -0
  99. package/package.json +2 -2
  100. package/second-brain/Projects/_Index.md +17 -4
  101. package/second-brain/Projects/sanook-cli/_Index.md +7 -3
  102. package/second-brain/Projects/sanook-cli/context.md +35 -0
  103. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  104. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  105. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  106. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +52 -11
  107. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  108. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  109. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  110. package/second-brain/Research/_Index.md +2 -0
  111. package/second-brain/Shared/Operating-State/current-state.md +14 -23
  112. package/second-brain/Shared/Tech-Standards/_Index.md +2 -0
  113. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  114. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  115. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  116. package/second-brain/Templates/project-workspace/context.md +28 -0
  117. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  118. package/second-brain/Templates/project-workspace/overview.md +39 -0
  119. package/second-brain/Templates/project-workspace/repo.md +33 -0
@@ -0,0 +1,277 @@
1
+ const I18N = {
2
+ en: {
3
+ productName: 'Sanook Dashboard',
4
+ tagline: 'Configure models, sessions, MCP, gateway, and your second brain',
5
+ nav: {
6
+ home: 'Home',
7
+ chat: 'Chat',
8
+ models: 'Models',
9
+ sessions: 'Sessions',
10
+ files: 'Files',
11
+ logs: 'Logs',
12
+ cron: 'Cron',
13
+ channels: 'Channels',
14
+ config: 'Config',
15
+ mcp: 'MCP',
16
+ brain: 'Brain',
17
+ },
18
+ home: {
19
+ title: 'System status',
20
+ cliVersion: 'CLI version',
21
+ model: 'Default model',
22
+ brainPath: 'Second brain',
23
+ gateway: 'Gateway hint',
24
+ openRepl: 'Run sanook in your terminal to chat',
25
+ },
26
+ chat: {
27
+ title: 'Chat',
28
+ hint: 'Primary chat runs in the terminal REPL. Start the gateway for HTTP/mobile access.',
29
+ },
30
+ models: { title: 'Models', hint: 'Change with sanook config set model <spec> or /model in REPL.' },
31
+ sessions: { title: 'Sessions', empty: 'No resumable sessions yet.' },
32
+ files: { title: 'Files', open: 'Open' },
33
+ logs: { title: 'Gateway logs', empty: 'No log file yet.' },
34
+ cron: { title: 'Scheduled tasks', empty: 'No cron tasks — sanook cron add "every 1h" "task"' },
35
+ channels: { title: 'Messaging channels', configured: 'configured', setup: 'Setup command' },
36
+ config: { title: 'Configuration', save: 'Save JSON' },
37
+ mcp: { title: 'MCP servers', empty: 'No MCP servers configured.' },
38
+ brain: { title: 'Second brain', empty: 'Not configured — run sanook brain init' },
39
+ },
40
+ th: {
41
+ productName: 'Sanook Dashboard',
42
+ tagline: 'จัดการ model, session, MCP, gateway และ second brain',
43
+ nav: {
44
+ home: 'หน้าแรก',
45
+ chat: 'Chat',
46
+ models: 'Models',
47
+ sessions: 'Sessions',
48
+ files: 'Files',
49
+ logs: 'Logs',
50
+ cron: 'Cron',
51
+ channels: 'Channels',
52
+ config: 'Config',
53
+ mcp: 'MCP',
54
+ brain: 'Brain',
55
+ },
56
+ home: {
57
+ title: 'สถานะระบบ',
58
+ cliVersion: 'เวอร์ชัน CLI',
59
+ model: 'Model หลัก',
60
+ brainPath: 'Second brain',
61
+ gateway: 'คำสั่ง Gateway',
62
+ openRepl: 'รัน sanook ใน terminal เพื่อแชท',
63
+ },
64
+ chat: { title: 'Chat', hint: 'แชทหลักอยู่ใน terminal · รัน sanook serve สำหรับ HTTP/mobile' },
65
+ models: { title: 'Models', hint: 'เปลี่ยนด้วย sanook config set model หรือ /model ใน REPL' },
66
+ sessions: { title: 'Sessions', empty: 'ยังไม่มี session ที่ resume ได้' },
67
+ files: { title: 'Files', open: 'เปิด' },
68
+ logs: { title: 'Gateway logs', empty: 'ยังไม่มี log' },
69
+ cron: { title: 'Scheduled tasks', empty: 'ยังไม่มี cron — sanook cron add "every 1h" "task"' },
70
+ channels: { title: 'Messaging channels', configured: 'ตั้งแล้ว', setup: 'คำสั่ง setup' },
71
+ config: { title: 'Configuration', save: 'บันทึก JSON' },
72
+ mcp: { title: 'MCP servers', empty: 'ยังไม่ได้ตั้ง MCP server' },
73
+ brain: { title: 'Second brain', empty: 'ยังไม่ตั้ง — รัน sanook brain init' },
74
+ },
75
+ };
76
+
77
+ const routes = [
78
+ { id: 'home', path: '#/' },
79
+ { id: 'chat', path: '#/chat' },
80
+ { id: 'models', path: '#/models' },
81
+ { id: 'sessions', path: '#/sessions' },
82
+ { id: 'files', path: '#/files' },
83
+ { id: 'logs', path: '#/logs' },
84
+ { id: 'cron', path: '#/cron' },
85
+ { id: 'channels', path: '#/channels' },
86
+ { id: 'config', path: '#/config' },
87
+ { id: 'mcp', path: '#/mcp' },
88
+ { id: 'brain', path: '#/brain' },
89
+ ];
90
+
91
+ let filesPath = '';
92
+
93
+ function locale() {
94
+ return localStorage.getItem('sanook-dashboard-locale') || 'en';
95
+ }
96
+
97
+ function t(key) {
98
+ const loc = I18N[locale()] ?? I18N.en;
99
+ return key.split('.').reduce((o, k) => o?.[k], loc) ?? key;
100
+ }
101
+
102
+ async function api(path) {
103
+ const res = await fetch(path);
104
+ if (!res.ok) throw new Error(`${path} ${res.status}`);
105
+ return res.json();
106
+ }
107
+
108
+ function renderNav(active) {
109
+ document.getElementById('nav').innerHTML = routes
110
+ .map((r) => `<a class="nav-link${active === r.id ? ' active' : ''}" href="${r.path}">${t(`nav.${r.id}`)}</a>`)
111
+ .join('');
112
+ }
113
+
114
+ async function renderHome(page) {
115
+ const status = await api('/api/status');
116
+ page.innerHTML = `<div class="card"><h2>${t('home.title')}</h2>
117
+ <dl class="kv">
118
+ <dt>${t('home.cliVersion')}</dt><dd><span class="pill">${status.version ?? 'dev'}</span></dd>
119
+ <dt>${t('home.model')}</dt><dd>${status.model ?? '(not set)'}</dd>
120
+ <dt>${t('home.brainPath')}</dt><dd>${status.brainPath ?? '(not set)'}</dd>
121
+ <dt>${t('home.gateway')}</dt><dd><code>${status.gatewayHint ?? 'sanook serve'}</code></dd>
122
+ </dl>
123
+ <p class="hint">${t('home.openRepl')}</p></div>`;
124
+ }
125
+
126
+ async function renderChat(page) {
127
+ const status = await api('/api/chat/status');
128
+ page.innerHTML = `<div class="card"><h2>${t('chat.title')}</h2>
129
+ <p class="hint">${t('chat.hint')}</p>
130
+ <dl class="kv"><dt>gateway</dt><dd><code>${status.gateway ?? 'sanook serve'}</code></dd></dl>
131
+ <textarea id="chat-draft" placeholder="Draft a prompt to copy into terminal…" style="width:100%;min-height:120px;background:#0a1020;color:#e8edf7;border:1px solid #2a3550;border-radius:12px;padding:12px;"></textarea>
132
+ </div>`;
133
+ }
134
+
135
+ async function renderModels(page) {
136
+ const status = await api('/api/status');
137
+ page.innerHTML = `<div class="card"><h2>${t('models.title')}</h2>
138
+ <dl class="kv"><dt>model</dt><dd>${status.model ?? '(not set)'}</dd></dl>
139
+ <p class="hint">${t('models.hint')}</p></div>`;
140
+ }
141
+
142
+ async function renderSessions(page) {
143
+ const { sessions } = await api('/api/sessions');
144
+ if (!sessions?.length) {
145
+ page.innerHTML = `<div class="card"><h2>${t('sessions.title')}</h2><p class="hint">${t('sessions.empty')}</p></div>`;
146
+ return;
147
+ }
148
+ page.innerHTML = `<div class="card"><h2>${t('sessions.title')}</h2>
149
+ <table class="table"><thead><tr><th>id</th><th>model</th><th>updated</th></tr></thead><tbody>
150
+ ${sessions.slice(0, 50).map((s) => `<tr><td>${s.id ?? ''}</td><td>${s.model ?? ''}</td><td>${s.updated ?? ''}</td></tr>`).join('')}
151
+ </tbody></table></div>`;
152
+ }
153
+
154
+ async function renderFiles(page) {
155
+ const data = await api(`/api/files?path=${encodeURIComponent(filesPath)}`);
156
+ const entries = (data.entries ?? [])
157
+ .map((e) => {
158
+ const next = filesPath ? `${filesPath}/${e.name}` : e.name;
159
+ if (e.dir) return `<tr><td>📁 ${e.name}</td><td><a href="#" data-path="${next}">${t('files.open')}</a></td></tr>`;
160
+ return `<tr><td>${e.name}</td><td><a href="#" data-read="${next}">${t('files.open')}</a></td></tr>`;
161
+ })
162
+ .join('');
163
+ page.innerHTML = `<div class="card"><h2>${t('files.title')}</h2>
164
+ <p class="hint">~/.sanook · path: ${filesPath || '/'}</p>
165
+ <table class="table"><thead><tr><th>name</th><th></th></tr></thead><tbody>${entries}</tbody></table>
166
+ <pre id="file-preview" class="hint" style="white-space:pre-wrap;margin-top:12px;"></pre></div>`;
167
+ page.querySelectorAll('[data-path]').forEach((el) => {
168
+ el.onclick = (ev) => {
169
+ ev.preventDefault();
170
+ filesPath = el.getAttribute('data-path') ?? '';
171
+ void renderFiles(page);
172
+ };
173
+ });
174
+ page.querySelectorAll('[data-read]').forEach((el) => {
175
+ el.onclick = async (ev) => {
176
+ ev.preventDefault();
177
+ const file = await api(`/api/files/read?path=${encodeURIComponent(el.getAttribute('data-read') ?? '')}`);
178
+ document.getElementById('file-preview').textContent = file.content ?? '';
179
+ };
180
+ });
181
+ }
182
+
183
+ async function renderLogs(page) {
184
+ const data = await api('/api/logs');
185
+ const body = (data.lines ?? []).join('\n') || t('logs.empty');
186
+ page.innerHTML = `<div class="card"><h2>${t('logs.title')}</h2>
187
+ <p class="hint">${data.path ?? ''}</p>
188
+ <pre style="white-space:pre-wrap;max-height:480px;overflow:auto;background:#0a1020;padding:12px;border-radius:12px;border:1px solid #2a3550;">${body}</pre></div>`;
189
+ }
190
+
191
+ async function renderCron(page) {
192
+ const { tasks } = await api('/api/cron');
193
+ if (!tasks?.length) {
194
+ page.innerHTML = `<div class="card"><h2>${t('cron.title')}</h2><p class="hint">${t('cron.empty')}</p></div>`;
195
+ return;
196
+ }
197
+ page.innerHTML = `<div class="card"><h2>${t('cron.title')}</h2>
198
+ <table class="table"><thead><tr><th>id</th><th>status</th><th>schedule</th><th>spec</th></tr></thead><tbody>
199
+ ${tasks.map((task) => `<tr><td>${task.id}</td><td>${task.status}</td><td>${task.schedule ?? 'once'}</td><td>${(task.spec ?? '').slice(0, 60)}</td></tr>`).join('')}
200
+ </tbody></table></div>`;
201
+ }
202
+
203
+ async function renderChannels(page) {
204
+ const data = await api('/api/channels');
205
+ page.innerHTML = `<div class="card"><h2>${t('channels.title')}</h2>
206
+ <p class="hint">service: ${data.serviceRunning ? 'running' : 'stopped'}</p>
207
+ <table class="table"><thead><tr><th>platform</th><th>status</th><th>${t('channels.setup')}</th></tr></thead><tbody>
208
+ ${(data.channels ?? []).map((c) => `<tr><td>${c.label}</td><td>${c.configured ? t('channels.configured') : '—'}</td><td><code>${c.setupCommand}</code></td></tr>`).join('')}
209
+ </tbody></table></div>`;
210
+ }
211
+
212
+ async function renderConfig(page) {
213
+ const config = await api('/api/config');
214
+ page.innerHTML = `<div class="card"><h2>${t('config.title')}</h2>
215
+ <textarea id="config-json" style="width:100%;min-height:320px;background:#0a1020;color:#e8edf7;border:1px solid #2a3550;border-radius:12px;padding:12px;font-family:ui-monospace,monospace;">${JSON.stringify(config, null, 2)}</textarea>
216
+ <p><button id="save-config" style="margin-top:12px;padding:8px 14px;border-radius:10px;border:0;background:#38bdf8;color:#041018;font-weight:600;cursor:pointer">${t('config.save')}</button></p></div>`;
217
+ document.getElementById('save-config').onclick = async () => {
218
+ await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: document.getElementById('config-json').value });
219
+ alert('Saved');
220
+ };
221
+ }
222
+
223
+ async function renderMcp(page) {
224
+ const { servers } = await api('/api/mcp');
225
+ const names = Object.keys(servers ?? {});
226
+ if (!names.length) {
227
+ page.innerHTML = `<div class="card"><h2>${t('mcp.title')}</h2><p class="hint">${t('mcp.empty')}</p></div>`;
228
+ return;
229
+ }
230
+ page.innerHTML = `<div class="card"><h2>${t('mcp.title')}</h2>
231
+ <table class="table"><thead><tr><th>name</th><th>command</th><th>enabled</th></tr></thead><tbody>
232
+ ${names.map((name) => { const s = servers[name]; return `<tr><td>${name}</td><td>${s.command ?? s.url ?? ''}</td><td>${s.enabled === false ? 'no' : 'yes'}</td></tr>`; }).join('')}
233
+ </tbody></table></div>`;
234
+ }
235
+
236
+ async function renderBrain(page) {
237
+ const { brainPath } = await api('/api/brain');
238
+ page.innerHTML = `<div class="card"><h2>${t('brain.title')}</h2>
239
+ <p>${brainPath ?? `<span class="hint">${t('brain.empty')}</span>`}</p></div>`;
240
+ }
241
+
242
+ async function renderRoute() {
243
+ const hash = location.hash.replace(/^#/, '') || '/';
244
+ const route = routes.find((r) => r.path === `#${hash}`) ?? routes[0];
245
+ document.getElementById('page-title').textContent = t(`nav.${route.id}`);
246
+ document.querySelector('.brand-title').textContent = t('productName');
247
+ document.querySelector('.brand-tagline').textContent = t('tagline');
248
+ renderNav(route.id);
249
+ const page = document.getElementById('page');
250
+ page.innerHTML = '<p class="hint">Loading…</p>';
251
+ try {
252
+ const map = {
253
+ home: renderHome,
254
+ chat: renderChat,
255
+ models: renderModels,
256
+ sessions: renderSessions,
257
+ files: renderFiles,
258
+ logs: renderLogs,
259
+ cron: renderCron,
260
+ channels: renderChannels,
261
+ config: renderConfig,
262
+ mcp: renderMcp,
263
+ brain: renderBrain,
264
+ };
265
+ await (map[route.id] ?? renderHome)(page);
266
+ } catch (e) {
267
+ page.innerHTML = `<div class="card"><p class="hint">${e.message}</p></div>`;
268
+ }
269
+ }
270
+
271
+ document.getElementById('locale-select').value = locale();
272
+ document.getElementById('locale-select').onchange = (e) => {
273
+ localStorage.setItem('sanook-dashboard-locale', e.target.value);
274
+ renderRoute();
275
+ };
276
+ window.addEventListener('hashchange', renderRoute);
277
+ renderRoute();
@@ -0,0 +1,39 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Sanook Dashboard</title>
7
+ <link rel="stylesheet" href="/styles.css" />
8
+ </head>
9
+ <body>
10
+ <div class="shell">
11
+ <aside class="sidebar">
12
+ <div class="brand">
13
+ <div class="brand-mark">S</div>
14
+ <div>
15
+ <div class="brand-title" data-i18n="productName">Sanook Dashboard</div>
16
+ <div class="brand-tagline" data-i18n="tagline">Configure your agent</div>
17
+ </div>
18
+ </div>
19
+ <nav id="nav"></nav>
20
+ <div class="sidebar-footer">
21
+ <label class="locale-switch">
22
+ <span>Lang</span>
23
+ <select id="locale-select">
24
+ <option value="en">EN</option>
25
+ <option value="th">TH</option>
26
+ </select>
27
+ </label>
28
+ </div>
29
+ </aside>
30
+ <main class="main">
31
+ <header class="page-header">
32
+ <h1 id="page-title">Home</h1>
33
+ </header>
34
+ <div id="page" class="page"></div>
35
+ </main>
36
+ </div>
37
+ <script type="module" src="/app.js"></script>
38
+ </body>
39
+ </html>
@@ -0,0 +1,85 @@
1
+ :root {
2
+ color-scheme: dark;
3
+ --bg: #0b1020;
4
+ --panel: #121829;
5
+ --panel-2: #1a2236;
6
+ --border: #2a3550;
7
+ --text: #e8edf7;
8
+ --muted: #9aa7c0;
9
+ --accent: #38bdf8;
10
+ --accent-2: #22d3ee;
11
+ --good: #4ade80;
12
+ --warn: #fbbf24;
13
+ font-family: "IBM Plex Sans", "Noto Sans Thai", system-ui, sans-serif;
14
+ }
15
+
16
+ * { box-sizing: border-box; }
17
+ body {
18
+ margin: 0;
19
+ background: radial-gradient(circle at top left, #132038, var(--bg) 45%);
20
+ color: var(--text);
21
+ min-height: 100vh;
22
+ }
23
+
24
+ .shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; }
25
+ .sidebar {
26
+ background: rgba(10, 14, 28, 0.92);
27
+ border-right: 1px solid var(--border);
28
+ padding: 1.25rem 1rem;
29
+ display: flex;
30
+ flex-direction: column;
31
+ gap: 1rem;
32
+ }
33
+ .brand { display: flex; gap: 0.75rem; align-items: center; }
34
+ .brand-mark {
35
+ width: 42px; height: 42px; border-radius: 12px;
36
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
37
+ display: grid; place-items: center; font-weight: 700; color: #041018;
38
+ }
39
+ .brand-title { font-weight: 700; font-size: 1.05rem; }
40
+ .brand-tagline { color: var(--muted); font-size: 0.78rem; line-height: 1.3; }
41
+ nav { display: flex; flex-direction: column; gap: 0.35rem; flex: 1; }
42
+ .nav-link {
43
+ color: var(--muted);
44
+ text-decoration: none;
45
+ padding: 0.55rem 0.75rem;
46
+ border-radius: 10px;
47
+ border: 1px solid transparent;
48
+ }
49
+ .nav-link.active, .nav-link:hover {
50
+ color: var(--text);
51
+ background: var(--panel-2);
52
+ border-color: var(--border);
53
+ }
54
+ .sidebar-footer { border-top: 1px solid var(--border); padding-top: 0.75rem; }
55
+ .locale-switch { display: flex; justify-content: space-between; align-items: center; color: var(--muted); font-size: 0.85rem; }
56
+ .locale-switch select {
57
+ background: var(--panel-2);
58
+ color: var(--text);
59
+ border: 1px solid var(--border);
60
+ border-radius: 8px;
61
+ padding: 0.25rem 0.5rem;
62
+ }
63
+
64
+ .main { padding: 1.5rem 2rem; }
65
+ .page-header h1 { margin: 0 0 1rem; font-size: 1.6rem; }
66
+ .page { display: grid; gap: 1rem; }
67
+ .card {
68
+ background: rgba(18, 24, 41, 0.88);
69
+ border: 1px solid var(--border);
70
+ border-radius: 16px;
71
+ padding: 1rem 1.1rem;
72
+ }
73
+ .card h2 { margin: 0 0 0.75rem; font-size: 1rem; color: var(--muted); font-weight: 600; }
74
+ .kv { display: grid; grid-template-columns: 180px 1fr; gap: 0.5rem 1rem; }
75
+ .kv dt { color: var(--muted); }
76
+ .kv dd { margin: 0; word-break: break-all; }
77
+ .table { width: 100%; border-collapse: collapse; }
78
+ .table th, .table td { text-align: left; padding: 0.55rem 0.4rem; border-bottom: 1px solid var(--border); }
79
+ .pill { display: inline-block; padding: 0.15rem 0.55rem; border-radius: 999px; background: rgba(56, 189, 248, 0.15); color: var(--accent); font-size: 0.8rem; }
80
+ .hint { color: var(--muted); font-size: 0.9rem; }
81
+
82
+ @media (max-width: 900px) {
83
+ .shell { grid-template-columns: 1fr; }
84
+ .sidebar { border-right: none; border-bottom: 1px solid var(--border); }
85
+ }
package/dist/diff.js CHANGED
@@ -28,9 +28,17 @@ export function renderEditDiff(oldStr, newStr) {
28
28
  }
29
29
  /** สรุปการ write — จำนวนบรรทัด/ตัวอักษร + ถ้าเขียนทับ บอก before→after */
30
30
  export function summarizeWrite(content, previous) {
31
- const lines = content === '' ? 0 : content.split('\n').length;
31
+ const lines = countLogicalLines(content);
32
32
  if (previous === undefined)
33
33
  return `เขียนใหม่ ${lines} บรรทัด (${content.length} ตัวอักษร)`;
34
- const prevLines = previous === '' ? 0 : previous.split('\n').length;
34
+ const prevLines = countLogicalLines(previous);
35
35
  return `เขียนทับ ${prevLines} → ${lines} บรรทัด (${content.length} ตัวอักษร)`;
36
36
  }
37
+ function countLogicalLines(content) {
38
+ if (content === '')
39
+ return 0;
40
+ const lines = content.split(/\r\n|\n|\r/);
41
+ if (/(\r\n|\n|\r)$/.test(content))
42
+ lines.pop();
43
+ return lines.length;
44
+ }
@@ -1,4 +1,4 @@
1
- import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
1
+ import { readFile, writeFile, mkdir, chmod, link, unlink } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { randomBytes, timingSafeEqual } from 'node:crypto';
4
4
  import { appHomePath } from '../brand.js';
@@ -18,17 +18,28 @@ export async function loadOrCreateToken() {
18
18
  const token = randomBytes(32).toString('hex');
19
19
  await ensureGatewayDir();
20
20
  try {
21
- await writeFile(TOKEN_FILE, `${token}\n`, { mode: 0o600, flag: 'wx' });
21
+ await createTokenFile(token);
22
22
  }
23
23
  catch (e) {
24
24
  if (e.code === 'EEXIST')
25
25
  continue;
26
26
  throw new Error(`ไม่สามารถเขียน gateway token ที่ ${TOKEN_FILE}: ${e.message}`);
27
27
  }
28
- await chmod(TOKEN_FILE, 0o600).catch(() => { });
29
28
  return token;
30
29
  }
31
30
  }
31
+ async function createTokenFile(token) {
32
+ const tempFile = join(GATEWAY_DIR, `.token-${process.pid}-${Date.now()}-${randomBytes(6).toString('hex')}.tmp`);
33
+ try {
34
+ await writeFile(tempFile, `${token}\n`, { mode: 0o600, flag: 'wx' });
35
+ await chmod(tempFile, 0o600).catch(() => { });
36
+ await link(tempFile, TOKEN_FILE);
37
+ await chmod(TOKEN_FILE, 0o600).catch(() => { });
38
+ }
39
+ finally {
40
+ await unlink(tempFile).catch(() => { });
41
+ }
42
+ }
32
43
  async function readTokenIfPresent() {
33
44
  let rawToken;
34
45
  try {
@@ -16,9 +16,51 @@ import { formatTarget, parseSendTarget } from './targets.js';
16
16
  import { sendTelegramMessage } from './telegram.js';
17
17
  import { sendTeamsMessage } from './teams.js';
18
18
  import { normalizeWhatsAppId, redactWhatsAppId, sendWhatsAppMessage } from './whatsapp.js';
19
- function deliveryText(message) {
19
+ const MOBILE_CHAT_PLATFORMS = new Set([
20
+ 'telegram',
21
+ 'discord',
22
+ 'slack',
23
+ 'mattermost',
24
+ 'line',
25
+ 'signal',
26
+ 'whatsapp',
27
+ 'matrix',
28
+ 'googlechat',
29
+ 'bluebubbles',
30
+ 'teams',
31
+ 'sms',
32
+ ]);
33
+ /** Shorten agent output for phone-sized chat surfaces — truncate fenced code, cap overall length. */
34
+ export function formatMobileChatReply(message, options = {}) {
35
+ const maxCodeBlockLines = options.maxCodeBlockLines ?? 8;
36
+ const maxCodeBlockChars = options.maxCodeBlockChars ?? 400;
37
+ const maxSummaryChars = options.maxSummaryChars ?? 3500;
38
+ let text = message.replace(/\r\n/g, '\n');
39
+ text = text.replace(/```([a-zA-Z0-9_-]*)\n?([\s\S]*?)```/g, (_match, lang, body) => {
40
+ const normalized = body.replace(/^\n/, '');
41
+ const lines = normalized.split('\n');
42
+ const tooLong = lines.length > maxCodeBlockLines || normalized.length > maxCodeBlockChars;
43
+ if (!tooLong)
44
+ return `\`\`\`${lang}\n${normalized}\`\`\``;
45
+ const truncated = lines.slice(0, maxCodeBlockLines).join('\n').slice(0, maxCodeBlockChars).trimEnd();
46
+ const label = lang ? lang : 'code';
47
+ return `\`\`\`${lang}\n${truncated}\n… (${label} truncated for mobile)\`\`\``;
48
+ });
49
+ text = text.replace(/\n{3,}/g, '\n\n').trim();
50
+ if (text.length > maxSummaryChars) {
51
+ text = `${text.slice(0, maxSummaryChars).trimEnd()}\n\n… (summary truncated for mobile)`;
52
+ }
53
+ return text || '(ไม่มีผลลัพธ์)';
54
+ }
55
+ function isMobileChatPlatform(platform) {
56
+ return MOBILE_CHAT_PLATFORMS.has(platform);
57
+ }
58
+ function deliveryText(message, platform) {
20
59
  const trimmed = message.trim();
21
- return trimmed || '(ไม่มีผลลัพธ์)';
60
+ const base = trimmed || '(ไม่มีผลลัพธ์)';
61
+ if (platform && isMobileChatPlatform(platform))
62
+ return formatMobileChatReply(base);
63
+ return base;
22
64
  }
23
65
  function normalizeBlueBubblesAllowTarget(config, raw) {
24
66
  const value = raw?.trim();
@@ -35,7 +77,7 @@ export async function deliverToTarget(rawTarget, message, options = {}) {
35
77
  const target = parseSendTarget(rawTarget);
36
78
  const config = options.config ?? (await readGatewayConfig());
37
79
  const env = options.env ?? process.env;
38
- const text = deliveryText(message);
80
+ const text = deliveryText(message, target.platform);
39
81
  if (target.platform === 'telegram') {
40
82
  const telegram = resolveTelegramConfig(config, env);
41
83
  if (!telegram.token)