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,224 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { closeSync, openSync } from 'node:fs';
3
+ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { appHomePath, BRAND } from '../brand.js';
7
+ const SERVICE_STATE_PATH = appHomePath('gateway', 'service.json');
8
+ const SERVICE_LOG_PATH = appHomePath('gateway', 'gateway.log');
9
+ export function gatewayServiceStatePath() {
10
+ return SERVICE_STATE_PATH;
11
+ }
12
+ export function gatewayServiceLogPath() {
13
+ return SERVICE_LOG_PATH;
14
+ }
15
+ export function processAlive(pid) {
16
+ if (!Number.isInteger(pid) || pid <= 0)
17
+ return false;
18
+ try {
19
+ process.kill(pid, 0);
20
+ return true;
21
+ }
22
+ catch (e) {
23
+ return e.code === 'EPERM';
24
+ }
25
+ }
26
+ export async function readGatewayServiceState() {
27
+ try {
28
+ const parsed = JSON.parse(await readFile(SERVICE_STATE_PATH, 'utf8'));
29
+ const pid = parsed.pid;
30
+ if (typeof pid !== 'number' || !Number.isInteger(pid) || !parsed.command || !Array.isArray(parsed.args))
31
+ return null;
32
+ return {
33
+ pid,
34
+ startedAt: typeof parsed.startedAt === 'string' ? parsed.startedAt : '',
35
+ command: parsed.command,
36
+ args: parsed.args.filter((a) => typeof a === 'string'),
37
+ cwd: typeof parsed.cwd === 'string' ? parsed.cwd : process.cwd(),
38
+ logPath: typeof parsed.logPath === 'string' ? parsed.logPath : SERVICE_LOG_PATH,
39
+ };
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ export async function gatewayServiceStatus() {
46
+ const state = await readGatewayServiceState();
47
+ return {
48
+ running: state ? processAlive(state.pid) : false,
49
+ state,
50
+ statePath: SERVICE_STATE_PATH,
51
+ logPath: SERVICE_LOG_PATH,
52
+ };
53
+ }
54
+ async function writeState(state) {
55
+ await mkdir(dirname(SERVICE_STATE_PATH), { recursive: true });
56
+ await writeFile(SERVICE_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
57
+ await chmod(SERVICE_STATE_PATH, 0o600).catch(() => { });
58
+ }
59
+ export async function clearGatewayServiceState() {
60
+ await rm(SERVICE_STATE_PATH, { force: true }).catch(() => { });
61
+ }
62
+ export async function startGatewayService(opts) {
63
+ const existing = await readGatewayServiceState();
64
+ if (existing && processAlive(existing.pid))
65
+ return { started: false, state: existing };
66
+ const command = opts.command ?? process.execPath;
67
+ const entrypoint = resolve(opts.entrypoint);
68
+ const args = [entrypoint, 'gateway', 'run', ...(opts.gatewayArgs ?? [])];
69
+ const cwd = opts.cwd ?? process.cwd();
70
+ await mkdir(dirname(SERVICE_LOG_PATH), { recursive: true });
71
+ const fd = openSync(SERVICE_LOG_PATH, 'a');
72
+ let child;
73
+ try {
74
+ child = (opts.spawnFn ?? spawn)(command, args, {
75
+ cwd,
76
+ detached: true,
77
+ env: opts.env ?? process.env,
78
+ stdio: ['ignore', fd, fd],
79
+ });
80
+ }
81
+ finally {
82
+ closeSync(fd);
83
+ }
84
+ child.unref();
85
+ if (!child.pid)
86
+ throw new Error('เริ่ม gateway service ไม่สำเร็จ: ไม่มี pid จาก child process');
87
+ const state = {
88
+ pid: child.pid,
89
+ startedAt: new Date().toISOString(),
90
+ command,
91
+ args,
92
+ cwd,
93
+ logPath: SERVICE_LOG_PATH,
94
+ };
95
+ await writeState(state);
96
+ return { started: true, state };
97
+ }
98
+ async function waitUntilStopped(pid, timeoutMs) {
99
+ const start = Date.now();
100
+ while (Date.now() - start < timeoutMs) {
101
+ if (!processAlive(pid))
102
+ return true;
103
+ await new Promise((r) => setTimeout(r, 100));
104
+ }
105
+ return !processAlive(pid);
106
+ }
107
+ export async function stopGatewayService(timeoutMs = 3000) {
108
+ const state = await readGatewayServiceState();
109
+ if (!state)
110
+ return { stopped: false, state: null };
111
+ if (!processAlive(state.pid)) {
112
+ await clearGatewayServiceState();
113
+ return { stopped: false, state };
114
+ }
115
+ process.kill(state.pid, 'SIGTERM');
116
+ const stopped = await waitUntilStopped(state.pid, timeoutMs);
117
+ if (!stopped && processAlive(state.pid)) {
118
+ try {
119
+ process.kill(state.pid, 'SIGKILL');
120
+ }
121
+ catch {
122
+ /* already gone */
123
+ }
124
+ }
125
+ await clearGatewayServiceState();
126
+ return { stopped: true, state };
127
+ }
128
+ function escapeXml(value) {
129
+ return value
130
+ .replaceAll('&', '&amp;')
131
+ .replaceAll('<', '&lt;')
132
+ .replaceAll('>', '&gt;')
133
+ .replaceAll('"', '&quot;')
134
+ .replaceAll("'", '&apos;');
135
+ }
136
+ function quoteSystemdArg(value) {
137
+ return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
138
+ }
139
+ function quoteCmdArg(value) {
140
+ return `"${value.replaceAll('"', '""')}"`;
141
+ }
142
+ export async function installGatewayService(entrypoint) {
143
+ const command = process.execPath;
144
+ const script = resolve(entrypoint);
145
+ if (process.platform === 'darwin') {
146
+ const path = join(homedir(), 'Library', 'LaunchAgents', `com.${BRAND.cliName}.gateway.plist`);
147
+ const log = SERVICE_LOG_PATH;
148
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
149
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
150
+ <plist version="1.0">
151
+ <dict>
152
+ <key>Label</key><string>com.${BRAND.cliName}.gateway</string>
153
+ <key>ProgramArguments</key>
154
+ <array>
155
+ <string>${escapeXml(command)}</string>
156
+ <string>${escapeXml(script)}</string>
157
+ <string>gateway</string>
158
+ <string>run</string>
159
+ </array>
160
+ <key>RunAtLoad</key><true/>
161
+ <key>KeepAlive</key><true/>
162
+ <key>StandardOutPath</key><string>${escapeXml(log)}</string>
163
+ <key>StandardErrorPath</key><string>${escapeXml(log)}</string>
164
+ </dict>
165
+ </plist>
166
+ `;
167
+ await mkdir(dirname(path), { recursive: true });
168
+ await writeFile(path, plist, { mode: 0o644 });
169
+ return {
170
+ path,
171
+ kind: 'launchd',
172
+ instructions: [`launchctl load ${path}`, `launchctl start com.${BRAND.cliName}.gateway`],
173
+ };
174
+ }
175
+ if (process.platform === 'linux') {
176
+ const path = join(homedir(), '.config', 'systemd', 'user', `${BRAND.cliName}-gateway.service`);
177
+ const unit = `[Unit]
178
+ Description=${BRAND.productName} Gateway
179
+
180
+ [Service]
181
+ ExecStart=${quoteSystemdArg(command)} ${quoteSystemdArg(script)} gateway run
182
+ Restart=always
183
+ WorkingDirectory=${quoteSystemdArg(process.cwd())}
184
+ StandardOutput=${quoteSystemdArg(`append:${SERVICE_LOG_PATH}`)}
185
+ StandardError=${quoteSystemdArg(`append:${SERVICE_LOG_PATH}`)}
186
+
187
+ [Install]
188
+ WantedBy=default.target
189
+ `;
190
+ await mkdir(dirname(path), { recursive: true });
191
+ await writeFile(path, unit, { mode: 0o644 });
192
+ return {
193
+ path,
194
+ kind: 'systemd',
195
+ instructions: ['systemctl --user daemon-reload', `systemctl --user enable --now ${BRAND.cliName}-gateway.service`],
196
+ };
197
+ }
198
+ const path = appHomePath('gateway', `${BRAND.cliName}-gateway.cmd`);
199
+ await mkdir(dirname(path), { recursive: true });
200
+ await writeFile(path, `${quoteCmdArg(command)} ${quoteCmdArg(script)} gateway run\r\n`, { mode: 0o700 });
201
+ return {
202
+ path,
203
+ kind: 'cmd',
204
+ instructions: [`Run ${path} from your preferred Windows service manager or Task Scheduler.`],
205
+ };
206
+ }
207
+ export async function uninstallGatewayService() {
208
+ const paths = [
209
+ join(homedir(), 'Library', 'LaunchAgents', `com.${BRAND.cliName}.gateway.plist`),
210
+ join(homedir(), '.config', 'systemd', 'user', `${BRAND.cliName}-gateway.service`),
211
+ appHomePath('gateway', `${BRAND.cliName}-gateway.cmd`),
212
+ ];
213
+ const removed = [];
214
+ for (const path of paths) {
215
+ try {
216
+ await rm(path, { force: true });
217
+ removed.push(path);
218
+ }
219
+ catch {
220
+ /* best effort */
221
+ }
222
+ }
223
+ return removed;
224
+ }
@@ -0,0 +1,362 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { chmod, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { appHomePath, persistenceEnabled } from '../brand.js';
5
+ import { runAgent } from '../loop.js';
6
+ import { redactKey, redactUnknown } from '../providers/keys.js';
7
+ import { canonicalSpec, parseSpec, PROVIDERS } from '../providers/registry.js';
8
+ import { autoCompact, estimateTokens } from '../compaction.js';
9
+ import { patchGlobalConfig } from '../config.js';
10
+ import { parseInsightsDays } from '../insights-args.js';
11
+ import { normalizePersonalityName, personalityListText } from '../personality.js';
12
+ import { patchGatewayConfig, readGatewayConfig } from './config.js';
13
+ const SESSION_DIR = appHomePath('gateway', 'sessions');
14
+ function safePlatformSegment(platform) {
15
+ const safe = platform.trim().replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
16
+ return safe || 'gateway';
17
+ }
18
+ export function gatewaySessionId(platform, target) {
19
+ const digest = createHash('sha256').update(`${platform}:${target}`).digest('hex').slice(0, 24);
20
+ return `${safePlatformSegment(platform)}-${digest}`;
21
+ }
22
+ function sessionPath(id) {
23
+ if (!/^[A-Za-z0-9_.-]+$/.test(id) || id.includes('..')) {
24
+ throw new Error(`gateway session id ไม่ถูกต้อง: ${id}`);
25
+ }
26
+ return join(SESSION_DIR, `${id}.json`);
27
+ }
28
+ function isRecord(value) {
29
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
30
+ }
31
+ function isModelMessage(value) {
32
+ if (!isRecord(value))
33
+ return false;
34
+ if (value.role === 'system')
35
+ return typeof value.content === 'string';
36
+ if (value.role === 'tool')
37
+ return Array.isArray(value.content);
38
+ if (value.role === 'user' || value.role === 'assistant') {
39
+ return typeof value.content === 'string' || Array.isArray(value.content);
40
+ }
41
+ return false;
42
+ }
43
+ function isGatewaySession(value) {
44
+ if (!isRecord(value))
45
+ return false;
46
+ return (typeof value.id === 'string' &&
47
+ typeof value.platform === 'string' &&
48
+ typeof value.target === 'string' &&
49
+ typeof value.created === 'string' &&
50
+ typeof value.updated === 'string' &&
51
+ typeof value.model === 'string' &&
52
+ Array.isArray(value.messages) &&
53
+ value.messages.every(isModelMessage));
54
+ }
55
+ export function shouldSuppressDelivery(text) {
56
+ const normalized = text.trim().toUpperCase().replace(/[\s_-]+/g, ' ');
57
+ return normalized === '[SILENT]' || normalized === 'SILENT' || normalized === 'NO REPLY';
58
+ }
59
+ export async function loadGatewaySession(platform, target) {
60
+ try {
61
+ const id = gatewaySessionId(platform, target);
62
+ const parsed = JSON.parse(await readFile(sessionPath(id), 'utf8'));
63
+ return isGatewaySession(parsed) && parsed.id === id ? parsed : null;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ export async function listGatewaySessions() {
70
+ try {
71
+ const files = (await readdir(SESSION_DIR)).filter((f) => f.endsWith('.json'));
72
+ const sessions = await Promise.all(files.map(async (file) => {
73
+ try {
74
+ const parsed = JSON.parse(await readFile(join(SESSION_DIR, file), 'utf8'));
75
+ return isGatewaySession(parsed) ? parsed : null;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }));
81
+ return sessions.filter((s) => s !== null).sort((a, b) => b.updated.localeCompare(a.updated));
82
+ }
83
+ catch {
84
+ return [];
85
+ }
86
+ }
87
+ export async function saveGatewaySession(session) {
88
+ if (!persistenceEnabled())
89
+ return;
90
+ await mkdir(SESSION_DIR, { recursive: true });
91
+ const safeSession = {
92
+ ...session,
93
+ messages: redactUnknown(session.messages),
94
+ };
95
+ await writeFile(sessionPath(session.id), `${JSON.stringify(safeSession, null, 2)}\n`, { mode: 0o600 });
96
+ await chmod(sessionPath(session.id), 0o600).catch(() => { });
97
+ }
98
+ export async function removeGatewaySession(platform, target) {
99
+ try {
100
+ await rm(sessionPath(gatewaySessionId(platform, target)));
101
+ return true;
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ }
107
+ function gatewayCommandHelp() {
108
+ return [
109
+ 'Messaging commands:',
110
+ '/new หรือ /reset — เริ่มบทสนทนาใหม่',
111
+ '/model [spec] — ดู/เปลี่ยน model ของ chat นี้',
112
+ '/personality [name] — ดู/ตั้ง personality overlay',
113
+ '/retry — รัน user turn ล่าสุดอีกครั้ง',
114
+ '/undo — ลบ exchange ล่าสุดจาก history',
115
+ '/compress — compact history ของ chat นี้',
116
+ '/usage — ดู usage โดยประมาณของ chat นี้',
117
+ '/insights [days] — ดู usage/session insights',
118
+ '/stop — หยุด turn ที่กำลังรัน (ถ้ามี)',
119
+ '/status — ดู session ปัจจุบัน',
120
+ '/sethome — ตั้ง chat นี้เป็น home target สำหรับ delivery/cron',
121
+ '/help — ดูคำสั่งที่รองรับ',
122
+ ].join('\n');
123
+ }
124
+ function messageText(content) {
125
+ if (typeof content === 'string')
126
+ return content;
127
+ if (Array.isArray(content)) {
128
+ return content
129
+ .map((part) => {
130
+ if (typeof part === 'string')
131
+ return part;
132
+ if (part && typeof part === 'object' && 'text' in part && typeof part.text === 'string') {
133
+ return part.text;
134
+ }
135
+ return '';
136
+ })
137
+ .filter(Boolean)
138
+ .join('\n');
139
+ }
140
+ return '';
141
+ }
142
+ function lastUserIndex(messages) {
143
+ for (let i = messages.length - 1; i >= 0; i--) {
144
+ if (messages[i].role === 'user')
145
+ return i;
146
+ }
147
+ return -1;
148
+ }
149
+ function trimLastExchange(messages) {
150
+ const userIdx = lastUserIndex(messages);
151
+ return userIdx === -1 ? messages : messages.slice(0, userIdx);
152
+ }
153
+ async function saveGatewayState(opts, existing, model, messages) {
154
+ const now = new Date().toISOString();
155
+ const session = {
156
+ id: gatewaySessionId(opts.platform, opts.target),
157
+ platform: opts.platform,
158
+ target: opts.target,
159
+ created: existing?.created ?? now,
160
+ updated: now,
161
+ model,
162
+ messages,
163
+ };
164
+ await saveGatewaySession(session);
165
+ return session;
166
+ }
167
+ async function runAndSaveGatewayTurn(opts, existing, prompt, history, model) {
168
+ const { text, messages } = await runAgent({
169
+ model,
170
+ prompt,
171
+ history,
172
+ maxSteps: opts.maxSteps ?? 20,
173
+ budgetUsd: opts.budgetUsd,
174
+ permissionMode: opts.permissionMode ?? 'ask',
175
+ });
176
+ await saveGatewayState(opts, existing, model, messages);
177
+ return { text, messages, suppressDelivery: shouldSuppressDelivery(text) };
178
+ }
179
+ function addFirst(items, item) {
180
+ return [item, ...(items ?? []).filter((x) => x !== item)];
181
+ }
182
+ async function setHomeTarget(platform, target) {
183
+ const cfg = await readGatewayConfig();
184
+ switch (platform) {
185
+ case 'telegram': {
186
+ const chatId = Number(target);
187
+ if (!Number.isInteger(chatId))
188
+ return 'Telegram /sethome ต้องมาจาก numeric chat id';
189
+ await patchGatewayConfig({ telegram: { allowedChatIds: addFirst(cfg.telegram?.allowedChatIds, chatId) } });
190
+ return `ตั้ง Telegram home/allowed chat เป็น ${chatId} แล้ว`;
191
+ }
192
+ case 'discord':
193
+ await patchGatewayConfig({ discord: { defaultChannelId: target, allowedChannelIds: addFirst(cfg.discord?.allowedChannelIds, target) } });
194
+ return `ตั้ง Discord home channel เป็น ${target} แล้ว`;
195
+ case 'slack':
196
+ await patchGatewayConfig({ slack: { defaultChannelId: target, allowedChannelIds: addFirst(cfg.slack?.allowedChannelIds, target) } });
197
+ return `ตั้ง Slack home channel เป็น ${target} แล้ว`;
198
+ case 'mattermost':
199
+ await patchGatewayConfig({ mattermost: { homeChannel: target, allowedChannels: addFirst(cfg.mattermost?.allowedChannels, target) } });
200
+ return `ตั้ง Mattermost home channel เป็น ${target} แล้ว`;
201
+ case 'homeassistant':
202
+ await patchGatewayConfig({ homeassistant: { homeChannel: target } });
203
+ return `ตั้ง Home Assistant home notification เป็น ${target} แล้ว`;
204
+ case 'email':
205
+ await patchGatewayConfig({ email: { homeAddress: target, allowedUsers: addFirst(cfg.email?.allowedUsers, target.toLowerCase()) } });
206
+ return `ตั้ง Email home address เป็น ${target} แล้ว`;
207
+ case 'line':
208
+ await patchGatewayConfig({ line: { homeChannel: target, allowedUsers: addFirst(cfg.line?.allowedUsers, target) } });
209
+ return `ตั้ง LINE home channel เป็น ${target} แล้ว`;
210
+ case 'sms':
211
+ await patchGatewayConfig({ sms: { homeChannel: target, allowedUsers: addFirst(cfg.sms?.allowedUsers, target) } });
212
+ return `ตั้ง SMS home channel เป็น ${target} แล้ว`;
213
+ case 'ntfy':
214
+ await patchGatewayConfig({ ntfy: { homeChannel: target, allowedUsers: addFirst(cfg.ntfy?.allowedUsers, target) } });
215
+ return `ตั้ง ntfy home topic เป็น ${target} แล้ว`;
216
+ case 'signal':
217
+ await patchGatewayConfig({ signal: { homeChannel: target, allowedUsers: addFirst(cfg.signal?.allowedUsers, target) } });
218
+ return `ตั้ง Signal home channel เป็น ${target} แล้ว`;
219
+ case 'whatsapp':
220
+ await patchGatewayConfig({ whatsapp: { homeChannel: target, allowedUsers: addFirst(cfg.whatsapp?.allowedUsers, target) } });
221
+ return `ตั้ง WhatsApp home channel เป็น ${target} แล้ว`;
222
+ case 'matrix':
223
+ await patchGatewayConfig({ matrix: { homeRoom: target, allowedRooms: addFirst(cfg.matrix?.allowedRooms, target) } });
224
+ return `ตั้ง Matrix home room เป็น ${target} แล้ว`;
225
+ case 'googlechat':
226
+ await patchGatewayConfig({ googleChat: { homeChannel: target, allowedSpaces: addFirst(cfg.googleChat?.allowedSpaces, target) } });
227
+ return `ตั้ง Google Chat home channel เป็น ${target} แล้ว`;
228
+ case 'bluebubbles':
229
+ await patchGatewayConfig({ bluebubbles: { homeChannel: target, allowedUsers: addFirst(cfg.bluebubbles?.allowedUsers, target) } });
230
+ return `ตั้ง BlueBubbles home channel เป็น ${target} แล้ว`;
231
+ case 'teams':
232
+ await patchGatewayConfig({ teams: { homeChannel: target, chatId: target } });
233
+ return `ตั้ง Teams home channel เป็น ${target} แล้ว`;
234
+ default:
235
+ return `platform ${platform} ยังไม่รองรับ /sethome`;
236
+ }
237
+ }
238
+ async function handleGatewayCommand(opts) {
239
+ const input = opts.userText?.trim();
240
+ if (!input?.startsWith('/'))
241
+ return null;
242
+ const [command, ...args] = input.slice(1).trim().split(/\s+/);
243
+ const normalized = command?.toLowerCase();
244
+ if (!normalized)
245
+ return null;
246
+ if (normalized === 'new' || normalized === 'reset') {
247
+ await removeGatewaySession(opts.platform, opts.target);
248
+ return { text: 'เริ่มบทสนทนาใหม่แล้ว', messages: [], suppressDelivery: false };
249
+ }
250
+ if (normalized === 'status') {
251
+ const existing = await loadGatewaySession(opts.platform, opts.target);
252
+ const turns = existing?.messages.length ?? 0;
253
+ const text = existing
254
+ ? [
255
+ `Session: ${existing.id}`,
256
+ `Platform: ${existing.platform}`,
257
+ `Target: ${existing.target}`,
258
+ `Model: ${existing.model}`,
259
+ `Messages: ${turns}`,
260
+ `Updated: ${existing.updated}`,
261
+ ].join('\n')
262
+ : `ยังไม่มี session สำหรับ ${opts.platform}:${opts.target}`;
263
+ return { text, messages: existing?.messages ?? [], suppressDelivery: false };
264
+ }
265
+ if (normalized === 'help') {
266
+ const existing = await loadGatewaySession(opts.platform, opts.target);
267
+ return { text: gatewayCommandHelp(), messages: existing?.messages ?? [], suppressDelivery: false };
268
+ }
269
+ if (normalized === 'sethome') {
270
+ const existing = await loadGatewaySession(opts.platform, opts.target);
271
+ return { text: await setHomeTarget(opts.platform, opts.target), messages: existing?.messages ?? [], suppressDelivery: false };
272
+ }
273
+ if (normalized === 'stop') {
274
+ const existing = await loadGatewaySession(opts.platform, opts.target);
275
+ return { text: 'ไม่มี turn ที่กำลังทำงานให้หยุดใน command นี้', messages: existing?.messages ?? [], suppressDelivery: false };
276
+ }
277
+ if (normalized === 'model') {
278
+ const existing = await loadGatewaySession(opts.platform, opts.target);
279
+ const currentModel = existing?.model ?? opts.model;
280
+ const spec = args[0];
281
+ if (!spec)
282
+ return { text: `model ปัจจุบัน: ${currentModel}`, messages: existing?.messages ?? [], suppressDelivery: false };
283
+ const canonical = canonicalSpec(spec);
284
+ const parsed = parseSpec(canonical);
285
+ if (!PROVIDERS[parsed.provider] || !parsed.model) {
286
+ return { text: `model spec ไม่รองรับ: ${spec}`, messages: existing?.messages ?? [], suppressDelivery: false };
287
+ }
288
+ const session = await saveGatewayState(opts, existing, canonical, existing?.messages ?? []);
289
+ return { text: `เปลี่ยน model ของ chat นี้ → ${canonical}`, messages: session.messages, suppressDelivery: false };
290
+ }
291
+ if (normalized === 'personality') {
292
+ const existing = await loadGatewaySession(opts.platform, opts.target);
293
+ const raw = args.join(' ').trim();
294
+ if (!raw)
295
+ return { text: personalityListText(), messages: existing?.messages ?? [], suppressDelivery: false };
296
+ const name = normalizePersonalityName(raw);
297
+ if (!name)
298
+ return { text: `ไม่รู้จัก personality: ${raw}\n\n${personalityListText()}`, messages: existing?.messages ?? [], suppressDelivery: false };
299
+ await patchGlobalConfig({ personality: name === 'none' ? undefined : name });
300
+ return {
301
+ text: name === 'none' ? 'ปิด personality overlay แล้ว' : `ตั้ง personality → ${name}`,
302
+ messages: existing?.messages ?? [],
303
+ suppressDelivery: false,
304
+ };
305
+ }
306
+ if (normalized === 'usage') {
307
+ const existing = await loadGatewaySession(opts.platform, opts.target);
308
+ const messages = existing?.messages ?? [];
309
+ return {
310
+ text: [
311
+ `messages: ${messages.length}`,
312
+ `approx tokens: ~${estimateTokens(messages)}`,
313
+ `model: ${existing?.model ?? opts.model}`,
314
+ ].join('\n'),
315
+ messages,
316
+ suppressDelivery: false,
317
+ };
318
+ }
319
+ if (normalized === 'insights') {
320
+ const existing = await loadGatewaySession(opts.platform, opts.target);
321
+ const days = parseInsightsDays(args);
322
+ if (days === null)
323
+ return { text: 'ใช้: /insights [days]', messages: existing?.messages ?? [], suppressDelivery: false };
324
+ const { renderInsights } = await import('../insights.js');
325
+ return { text: await renderInsights({ days, cwd: null, includeGateway: true }), messages: existing?.messages ?? [], suppressDelivery: false };
326
+ }
327
+ if (normalized === 'compress') {
328
+ const existing = await loadGatewaySession(opts.platform, opts.target);
329
+ if (!existing?.messages.length)
330
+ return { text: 'ยังไม่มี history ให้ compact', messages: [], suppressDelivery: false };
331
+ const before = estimateTokens(existing.messages);
332
+ const messages = autoCompact(existing.messages, 40_000, 20);
333
+ await saveGatewayState(opts, existing, existing.model, messages);
334
+ return { text: `compact แล้ว: ~${before} → ~${estimateTokens(messages)} tokens`, messages, suppressDelivery: false };
335
+ }
336
+ if (normalized === 'undo') {
337
+ const existing = await loadGatewaySession(opts.platform, opts.target);
338
+ if (!existing?.messages.length)
339
+ return { text: 'ยังไม่มี turn ให้ undo', messages: [], suppressDelivery: false };
340
+ const messages = trimLastExchange(existing.messages);
341
+ await saveGatewayState(opts, existing, existing.model, messages);
342
+ return { text: 'undo exchange ล่าสุดแล้ว', messages, suppressDelivery: false };
343
+ }
344
+ if (normalized === 'retry') {
345
+ const existing = await loadGatewaySession(opts.platform, opts.target);
346
+ const idx = existing ? lastUserIndex(existing.messages) : -1;
347
+ if (!existing || idx === -1)
348
+ return { text: 'ยังไม่มี user turn ให้ retry', messages: existing?.messages ?? [], suppressDelivery: false };
349
+ const prompt = messageText(existing.messages[idx].content).trim();
350
+ if (!prompt)
351
+ return { text: 'user turn ล่าสุดว่าง retry ไม่ได้', messages: existing.messages, suppressDelivery: false };
352
+ return runAndSaveGatewayTurn(opts, existing, prompt, existing.messages.slice(0, idx), existing.model);
353
+ }
354
+ return null;
355
+ }
356
+ export async function runGatewayAgent(opts) {
357
+ const command = await handleGatewayCommand(opts);
358
+ if (command)
359
+ return command;
360
+ const existing = await loadGatewaySession(opts.platform, opts.target);
361
+ return runAndSaveGatewayTurn(opts, existing, opts.prompt, existing?.messages ?? [], existing?.model ?? opts.model);
362
+ }