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.
- package/.env.example +161 -3
- package/CHANGELOG.md +83 -5
- package/README.md +240 -23
- package/README.th.md +87 -6
- package/dist/approval.js +6 -0
- package/dist/bin.js +3045 -210
- package/dist/brain-context.js +223 -0
- package/dist/brain-doctor.js +318 -0
- package/dist/brain-eval.js +186 -0
- package/dist/brain-final.js +371 -0
- package/dist/brain-review.js +382 -0
- package/dist/brain.js +12 -1
- package/dist/brand.js +1 -1
- package/dist/cli-args.js +152 -0
- package/dist/cli-option-values.js +16 -0
- package/dist/commands.js +172 -13
- package/dist/compaction.js +96 -11
- package/dist/config.js +118 -28
- package/dist/context-compression.js +191 -0
- package/dist/cost.js +49 -15
- package/dist/first-run.js +21 -0
- package/dist/gateway/auth.js +37 -8
- package/dist/gateway/bluebubbles.js +205 -0
- package/dist/gateway/config.js +929 -0
- package/dist/gateway/deliver.js +357 -0
- package/dist/gateway/discord.js +124 -0
- package/dist/gateway/email.js +472 -0
- package/dist/gateway/googlechat.js +207 -0
- package/dist/gateway/homeassistant.js +256 -0
- package/dist/gateway/ledger.js +18 -0
- package/dist/gateway/line.js +171 -0
- package/dist/gateway/lock.js +3 -1
- package/dist/gateway/matrix.js +366 -0
- package/dist/gateway/mattermost.js +322 -0
- package/dist/gateway/ntfy.js +218 -0
- package/dist/gateway/schedule.js +31 -4
- package/dist/gateway/serve.js +267 -7
- package/dist/gateway/server.js +253 -19
- package/dist/gateway/service.js +224 -0
- package/dist/gateway/session.js +343 -0
- package/dist/gateway/signal.js +351 -0
- package/dist/gateway/slack.js +124 -0
- package/dist/gateway/sms.js +169 -0
- package/dist/gateway/targets.js +576 -0
- package/dist/gateway/teams.js +106 -0
- package/dist/gateway/telegram.js +38 -15
- package/dist/gateway/webhooks.js +220 -0
- package/dist/gateway/whatsapp.js +230 -0
- package/dist/hooks.js +13 -2
- package/dist/insights-args.js +35 -0
- package/dist/insights.js +86 -0
- package/dist/loop.js +123 -24
- package/dist/lsp/index.js +23 -5
- package/dist/mcp-registry.js +350 -0
- package/dist/mcp-server.js +1 -1
- package/dist/mcp.js +44 -6
- package/dist/memory.js +100 -33
- package/dist/orchestrate.js +49 -19
- package/dist/personality.js +58 -0
- package/dist/providers/codex.js +86 -38
- package/dist/providers/keys.js +1 -1
- package/dist/providers/models.js +22 -6
- package/dist/providers/registry.js +38 -49
- package/dist/search/chunk.js +7 -8
- package/dist/search/cli.js +75 -0
- package/dist/search/embed-store.js +3 -0
- package/dist/search/indexer.js +44 -1
- package/dist/search/store.js +23 -1
- package/dist/session.js +93 -7
- package/dist/skill-install.js +29 -12
- package/dist/support-dump.js +175 -0
- package/dist/tools/edit.js +45 -15
- package/dist/tools/git.js +10 -5
- package/dist/tools/homeassistant.js +106 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/list.js +19 -6
- package/dist/tools/permission.js +923 -9
- package/dist/tools/read.js +16 -4
- package/dist/tools/schedule.js +19 -3
- package/dist/tools/search.js +217 -13
- package/dist/tools/task.js +18 -7
- package/dist/tools/timeout.js +21 -3
- package/dist/trust.js +11 -1
- package/dist/ui/app.js +57 -11
- package/dist/ui/brain-wizard.js +2 -2
- package/dist/ui/history.js +37 -5
- package/dist/ui/mentions.js +3 -2
- package/dist/ui/render.js +55 -15
- package/dist/ui/setup.js +107 -10
- package/dist/update.js +24 -11
- package/dist/worktree.js +175 -4
- package/package.json +4 -4
- package/second-brain/AGENTS.md +6 -4
- package/second-brain/CLAUDE.md +7 -1
- package/second-brain/Evals/_Index.md +10 -2
- package/second-brain/Evals/quality-ledger.md +9 -1
- package/second-brain/Evals/second-brain-benchmarks.md +62 -0
- package/second-brain/GEMINI.md +5 -4
- package/second-brain/Home.md +1 -1
- package/second-brain/Projects/_Index.md +3 -1
- package/second-brain/Projects/sanook-cli/_Index.md +26 -0
- package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +156 -0
- package/second-brain/README.md +1 -1
- package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
- package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
- package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
- package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
- package/second-brain/Research/_Index.md +6 -1
- package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
- package/second-brain/Reviews/_Index.md +1 -1
- package/second-brain/Runbooks/_Index.md +6 -1
- package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
- package/second-brain/SANOOK.md +45 -0
- package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
- package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
- package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
- package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
- package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
- package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
- package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
- package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
- package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
- package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
- package/second-brain/Sessions/_Index.md +15 -1
- package/second-brain/Shared/AI-Context-Index.md +22 -0
- package/second-brain/Shared/Context-Packs/_Index.md +9 -1
- package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
- package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
- package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
- package/second-brain/Shared/Operating-State/current-state.md +22 -3
- package/second-brain/Shared/Scripts/_Index.md +3 -1
- package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
- package/second-brain/Shared/Tech-Standards/_Index.md +4 -1
- package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
- package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
- package/second-brain/Shared/User-Memory/_Index.md +4 -1
- package/second-brain/Shared/User-Memory/response-examples.md +98 -0
- package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
- package/second-brain/Templates/_Index.md +9 -0
- package/second-brain/Templates/final-lite.md +111 -0
- package/second-brain/Templates/final.md +231 -0
- package/second-brain/Vault Structure Map.md +2 -1
- package/skills/structured-output-llm/SKILL.md +1 -1
package/dist/gateway/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { listTasks, enqueueTask } from './ledger.js';
|
|
3
3
|
import { parseSchedule } from './schedule.js';
|
|
4
|
+
import { formatTarget, parseSendTarget } from './targets.js';
|
|
4
5
|
import { tokenMatches } from './auth.js';
|
|
5
6
|
import { runAgent } from '../loop.js';
|
|
6
7
|
import { redactKey } from '../providers/keys.js';
|
|
@@ -9,21 +10,111 @@ function send(res, status, body) {
|
|
|
9
10
|
res.writeHead(status, { 'content-type': 'application/json' });
|
|
10
11
|
res.end(JSON.stringify(body));
|
|
11
12
|
}
|
|
13
|
+
function sendRaw(res, status, contentType, body) {
|
|
14
|
+
res.writeHead(status, { 'content-type': contentType });
|
|
15
|
+
res.end(body);
|
|
16
|
+
}
|
|
17
|
+
function sendSse(res, body) {
|
|
18
|
+
res.write(`data: ${typeof body === 'string' ? body : JSON.stringify(body)}\n\n`);
|
|
19
|
+
}
|
|
20
|
+
export function optionalString(value) {
|
|
21
|
+
if (typeof value !== 'string')
|
|
22
|
+
return undefined;
|
|
23
|
+
const trimmed = value.trim();
|
|
24
|
+
return trimmed || undefined;
|
|
25
|
+
}
|
|
26
|
+
export function parseBearerToken(authorization) {
|
|
27
|
+
if (!authorization)
|
|
28
|
+
return undefined;
|
|
29
|
+
const tokenMatch = /^Bearer +(\S+)$/i.exec(authorization);
|
|
30
|
+
if (!tokenMatch)
|
|
31
|
+
return undefined;
|
|
32
|
+
return tokenMatch[1];
|
|
33
|
+
}
|
|
34
|
+
export function parseWebhookRouteName(pathname) {
|
|
35
|
+
if (!pathname.startsWith('/webhooks/'))
|
|
36
|
+
return undefined;
|
|
37
|
+
try {
|
|
38
|
+
const routeName = decodeURIComponent(pathname.slice('/webhooks/'.length)).replace(/^\/+|\/+$/g, '');
|
|
39
|
+
return /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(routeName) ? routeName : undefined;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function parseOptionalSchedule(value, now) {
|
|
46
|
+
if (value == null)
|
|
47
|
+
return { schedule: null };
|
|
48
|
+
if (typeof value !== 'string')
|
|
49
|
+
return { schedule: null, invalid: 'ต้องเป็นข้อความ' };
|
|
50
|
+
const scheduleText = optionalString(value);
|
|
51
|
+
if (!scheduleText)
|
|
52
|
+
return { schedule: null };
|
|
53
|
+
const schedule = parseSchedule(scheduleText, now);
|
|
54
|
+
return schedule ? { schedule } : { schedule: null, invalid: scheduleText };
|
|
55
|
+
}
|
|
56
|
+
export function parseRequiredTaskSpec(value) {
|
|
57
|
+
if (typeof value !== 'string') {
|
|
58
|
+
return value == null ? { invalid: 'ต้องมี spec' } : { invalid: 'spec ต้องเป็นข้อความ' };
|
|
59
|
+
}
|
|
60
|
+
const spec = value.trim();
|
|
61
|
+
return spec ? { spec } : { invalid: 'ต้องมี spec' };
|
|
62
|
+
}
|
|
63
|
+
export function parseOptionalDeliverTarget(value) {
|
|
64
|
+
if (value == null)
|
|
65
|
+
return {};
|
|
66
|
+
if (typeof value !== 'string')
|
|
67
|
+
return { invalid: 'deliver ต้องเป็นข้อความ' };
|
|
68
|
+
const deliverText = optionalString(value);
|
|
69
|
+
if (!deliverText)
|
|
70
|
+
return {};
|
|
71
|
+
try {
|
|
72
|
+
return { deliver: formatTarget(parseSendTarget(deliverText)) };
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
return { invalid: e.message };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function parseOptionalTaskModel(value) {
|
|
79
|
+
if (value == null)
|
|
80
|
+
return {};
|
|
81
|
+
if (typeof value !== 'string')
|
|
82
|
+
return { invalid: 'model ต้องเป็นข้อความ' };
|
|
83
|
+
const model = optionalString(value);
|
|
84
|
+
return model ? { model } : {};
|
|
85
|
+
}
|
|
12
86
|
const MAX_BODY = 1_000_000; // 1MB กัน memory blowup
|
|
87
|
+
/** error ที่พก HTTP status — ให้ client เห็น 400/413 (client error) แทน 500 (server error) */
|
|
88
|
+
class HttpError extends Error {
|
|
89
|
+
status;
|
|
90
|
+
constructor(status, message) {
|
|
91
|
+
super(message);
|
|
92
|
+
this.status = status;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
13
95
|
async function readBody(req) {
|
|
96
|
+
const raw = await readRawBody(req);
|
|
97
|
+
if (!raw)
|
|
98
|
+
return {};
|
|
99
|
+
let parsed;
|
|
100
|
+
try {
|
|
101
|
+
parsed = JSON.parse(raw);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
throw new HttpError(400, 'invalid JSON body'); // Bad Request — ไม่ leak ข้อความ parser
|
|
105
|
+
}
|
|
106
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
107
|
+
}
|
|
108
|
+
async function readRawBody(req) {
|
|
14
109
|
const chunks = [];
|
|
15
110
|
let size = 0;
|
|
16
111
|
for await (const c of req) {
|
|
17
112
|
size += c.length;
|
|
18
113
|
if (size > MAX_BODY)
|
|
19
|
-
throw new
|
|
114
|
+
throw new HttpError(413, 'request body ใหญ่เกิน'); // Payload Too Large
|
|
20
115
|
chunks.push(c);
|
|
21
116
|
}
|
|
22
|
-
|
|
23
|
-
if (!raw)
|
|
24
|
-
return {};
|
|
25
|
-
const parsed = JSON.parse(raw);
|
|
26
|
-
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
117
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
27
118
|
}
|
|
28
119
|
/**
|
|
29
120
|
* gateway HTTP — bind 127.0.0.1 เท่านั้น (loopback, ไม่ expose ออกเน็ต), ทุก endpoint ยกเว้น /health ต้อง bearer token
|
|
@@ -33,7 +124,7 @@ async function readBody(req) {
|
|
|
33
124
|
export function startServer(opts) {
|
|
34
125
|
const server = createServer((req, res) => {
|
|
35
126
|
// redact กัน API key/secret รั่วใน error response (provider error อาจฝัง key)
|
|
36
|
-
void handle(req, res, opts).catch((err) => send(res, 500, { error: redactKey(err.message ?? String(err)) }));
|
|
127
|
+
void handle(req, res, opts).catch((err) => send(res, err.status ?? 500, { error: redactKey(err.message ?? String(err)) }));
|
|
37
128
|
});
|
|
38
129
|
// '127.0.0.1' = loopback only — สำคัญ: ห้าม 0.0.0.0 (จะเปิดให้ทั้ง LAN)
|
|
39
130
|
server.listen(opts.port, '127.0.0.1', () => opts.onLog?.(`http://127.0.0.1:${opts.port} (loopback)`));
|
|
@@ -45,9 +136,102 @@ async function handle(req, res, opts) {
|
|
|
45
136
|
if (req.method === 'GET' && url.pathname === '/health') {
|
|
46
137
|
return send(res, 200, { ok: true, service: BRAND.gatewayServiceName });
|
|
47
138
|
}
|
|
139
|
+
if (req.method === 'GET' && url.pathname === '/line/webhook/health') {
|
|
140
|
+
return send(res, 200, { status: 'ok', platform: 'line' });
|
|
141
|
+
}
|
|
142
|
+
if (req.method === 'GET' && url.pathname === '/sms/webhook/health') {
|
|
143
|
+
return send(res, 200, { status: 'ok', platform: 'sms' });
|
|
144
|
+
}
|
|
145
|
+
if (req.method === 'GET' && url.pathname === '/whatsapp/webhook/health') {
|
|
146
|
+
const { readGatewayConfig, resolveWhatsAppConfig } = await import('./config.js');
|
|
147
|
+
const whatsapp = resolveWhatsAppConfig(await readGatewayConfig());
|
|
148
|
+
return send(res, 200, {
|
|
149
|
+
status: 'ok',
|
|
150
|
+
platform: 'whatsapp',
|
|
151
|
+
phone_number_id_configured: Boolean(whatsapp.phoneNumberId),
|
|
152
|
+
access_token_configured: Boolean(whatsapp.accessToken),
|
|
153
|
+
app_secret_configured: Boolean(whatsapp.appSecret),
|
|
154
|
+
verify_token_configured: Boolean(whatsapp.verifyToken),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (req.method === 'GET' && url.pathname === '/webhooks/health') {
|
|
158
|
+
return send(res, 200, { status: 'ok', platform: 'webhook' });
|
|
159
|
+
}
|
|
160
|
+
if (req.method === 'POST' && url.pathname === '/line/webhook') {
|
|
161
|
+
const rawBody = await readRawBody(req);
|
|
162
|
+
const signature = Array.isArray(req.headers['x-line-signature']) ? req.headers['x-line-signature'][0] : req.headers['x-line-signature'];
|
|
163
|
+
const { readGatewayConfig, resolveLineConfig } = await import('./config.js');
|
|
164
|
+
const { handleLineWebhook } = await import('./line.js');
|
|
165
|
+
const result = await handleLineWebhook({
|
|
166
|
+
rawBody,
|
|
167
|
+
signature,
|
|
168
|
+
config: resolveLineConfig(await readGatewayConfig()),
|
|
169
|
+
model: opts.defaultModel,
|
|
170
|
+
budgetUsd: opts.budgetUsd,
|
|
171
|
+
permissionMode: opts.permissionMode ?? 'ask',
|
|
172
|
+
onLog: opts.onLog,
|
|
173
|
+
});
|
|
174
|
+
return send(res, result.status, result.body);
|
|
175
|
+
}
|
|
176
|
+
if (req.method === 'POST' && url.pathname === '/sms/webhook') {
|
|
177
|
+
const rawBody = await readRawBody(req);
|
|
178
|
+
const signature = Array.isArray(req.headers['x-twilio-signature']) ? req.headers['x-twilio-signature'][0] : req.headers['x-twilio-signature'];
|
|
179
|
+
const { readGatewayConfig, resolveSmsConfig } = await import('./config.js');
|
|
180
|
+
const { handleSmsWebhook } = await import('./sms.js');
|
|
181
|
+
const result = await handleSmsWebhook({
|
|
182
|
+
rawBody,
|
|
183
|
+
signature,
|
|
184
|
+
config: resolveSmsConfig(await readGatewayConfig()),
|
|
185
|
+
model: opts.defaultModel,
|
|
186
|
+
budgetUsd: opts.budgetUsd,
|
|
187
|
+
permissionMode: opts.permissionMode ?? 'ask',
|
|
188
|
+
onLog: opts.onLog,
|
|
189
|
+
});
|
|
190
|
+
return sendRaw(res, result.status, result.contentType, result.body);
|
|
191
|
+
}
|
|
192
|
+
if (req.method === 'GET' && url.pathname === '/whatsapp/webhook') {
|
|
193
|
+
const { readGatewayConfig, resolveWhatsAppConfig } = await import('./config.js');
|
|
194
|
+
const { handleWhatsAppChallenge } = await import('./whatsapp.js');
|
|
195
|
+
const result = handleWhatsAppChallenge(resolveWhatsAppConfig(await readGatewayConfig()), url.searchParams);
|
|
196
|
+
return sendRaw(res, result.status, result.contentType, result.body);
|
|
197
|
+
}
|
|
198
|
+
if (req.method === 'POST' && url.pathname === '/whatsapp/webhook') {
|
|
199
|
+
const rawBody = await readRawBody(req);
|
|
200
|
+
const signature = Array.isArray(req.headers['x-hub-signature-256']) ? req.headers['x-hub-signature-256'][0] : req.headers['x-hub-signature-256'];
|
|
201
|
+
const { readGatewayConfig, resolveWhatsAppConfig } = await import('./config.js');
|
|
202
|
+
const { handleWhatsAppWebhook } = await import('./whatsapp.js');
|
|
203
|
+
const result = await handleWhatsAppWebhook({
|
|
204
|
+
rawBody,
|
|
205
|
+
signature,
|
|
206
|
+
config: resolveWhatsAppConfig(await readGatewayConfig()),
|
|
207
|
+
model: opts.defaultModel,
|
|
208
|
+
budgetUsd: opts.budgetUsd,
|
|
209
|
+
permissionMode: opts.permissionMode ?? 'ask',
|
|
210
|
+
onLog: opts.onLog,
|
|
211
|
+
});
|
|
212
|
+
return send(res, result.status, result.body);
|
|
213
|
+
}
|
|
214
|
+
if (req.method === 'POST' && url.pathname.startsWith('/webhooks/')) {
|
|
215
|
+
const routeName = parseWebhookRouteName(url.pathname);
|
|
216
|
+
if (!routeName)
|
|
217
|
+
return send(res, 400, { error: 'invalid_webhook_route' });
|
|
218
|
+
const rawBody = await readRawBody(req);
|
|
219
|
+
const { readGatewayConfig, resolveWebhookConfig } = await import('./config.js');
|
|
220
|
+
const { handleWebhookRequest } = await import('./webhooks.js');
|
|
221
|
+
const result = await handleWebhookRequest({
|
|
222
|
+
routeName,
|
|
223
|
+
rawBody,
|
|
224
|
+
headers: req.headers,
|
|
225
|
+
config: resolveWebhookConfig(await readGatewayConfig()),
|
|
226
|
+
model: opts.defaultModel,
|
|
227
|
+
budgetUsd: opts.budgetUsd,
|
|
228
|
+
permissionMode: opts.permissionMode ?? 'ask',
|
|
229
|
+
onLog: opts.onLog,
|
|
230
|
+
});
|
|
231
|
+
return send(res, result.status, result.body);
|
|
232
|
+
}
|
|
48
233
|
// ทุก endpoint อื่น → bearer token
|
|
49
|
-
const
|
|
50
|
-
const provided = auth.startsWith('Bearer ') ? auth.slice(7) : undefined;
|
|
234
|
+
const provided = parseBearerToken(req.headers.authorization);
|
|
51
235
|
if (!tokenMatches(opts.token, provided)) {
|
|
52
236
|
return send(res, 401, { error: 'unauthorized' });
|
|
53
237
|
}
|
|
@@ -63,8 +247,51 @@ async function handle(req, res, opts) {
|
|
|
63
247
|
const history = msgs
|
|
64
248
|
.slice(0, lastUserIdx)
|
|
65
249
|
.map((m) => ({ role: m.role, content: m.content }));
|
|
66
|
-
const
|
|
67
|
-
|
|
250
|
+
const { model: requestedModel, invalid: invalidModel } = parseOptionalTaskModel(body.model);
|
|
251
|
+
if (invalidModel)
|
|
252
|
+
return send(res, 400, { error: invalidModel });
|
|
253
|
+
const model = requestedModel ?? opts.defaultModel;
|
|
254
|
+
const runner = opts.runner ?? runAgent;
|
|
255
|
+
if (body.stream === true) {
|
|
256
|
+
res.writeHead(200, {
|
|
257
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
258
|
+
'cache-control': 'no-cache, no-transform',
|
|
259
|
+
connection: 'keep-alive',
|
|
260
|
+
});
|
|
261
|
+
sendSse(res, {
|
|
262
|
+
object: 'chat.completion.chunk',
|
|
263
|
+
model,
|
|
264
|
+
choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],
|
|
265
|
+
});
|
|
266
|
+
try {
|
|
267
|
+
await runner({
|
|
268
|
+
model,
|
|
269
|
+
prompt,
|
|
270
|
+
history,
|
|
271
|
+
maxSteps: 20,
|
|
272
|
+
budgetUsd: opts.budgetUsd,
|
|
273
|
+
permissionMode: opts.permissionMode ?? 'ask',
|
|
274
|
+
onEvent: (e) => {
|
|
275
|
+
if (e.type !== 'text' || !e.text)
|
|
276
|
+
return;
|
|
277
|
+
sendSse(res, {
|
|
278
|
+
object: 'chat.completion.chunk',
|
|
279
|
+
model,
|
|
280
|
+
choices: [{ index: 0, delta: { content: e.text }, finish_reason: null }],
|
|
281
|
+
});
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
sendSse(res, { object: 'chat.completion.chunk', model, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] });
|
|
285
|
+
sendSse(res, '[DONE]');
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
sendSse(res, { error: redactKey(e.message ?? String(e)) });
|
|
289
|
+
sendSse(res, '[DONE]');
|
|
290
|
+
}
|
|
291
|
+
res.end();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const { text } = await runner({
|
|
68
295
|
model,
|
|
69
296
|
prompt,
|
|
70
297
|
history,
|
|
@@ -83,17 +310,24 @@ async function handle(req, res, opts) {
|
|
|
83
310
|
}
|
|
84
311
|
if (req.method === 'POST' && url.pathname === '/tasks') {
|
|
85
312
|
const body = await readBody(req);
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
88
|
-
return send(res, 400, { error:
|
|
89
|
-
const sched
|
|
90
|
-
if (
|
|
91
|
-
return send(res, 400, { error: `schedule ไม่ถูกต้อง: ${
|
|
313
|
+
const specInput = parseRequiredTaskSpec(body.spec);
|
|
314
|
+
if ('invalid' in specInput)
|
|
315
|
+
return send(res, 400, { error: specInput.invalid });
|
|
316
|
+
const { schedule: sched, invalid } = parseOptionalSchedule(body.schedule, Date.now());
|
|
317
|
+
if (invalid)
|
|
318
|
+
return send(res, 400, { error: `schedule ไม่ถูกต้อง: ${invalid}` });
|
|
319
|
+
const { model, invalid: invalidModel } = parseOptionalTaskModel(body.model);
|
|
320
|
+
if (invalidModel)
|
|
321
|
+
return send(res, 400, { error: invalidModel });
|
|
322
|
+
const { deliver, invalid: invalidDeliver } = parseOptionalDeliverTarget(body.deliver);
|
|
323
|
+
if (invalidDeliver)
|
|
324
|
+
return send(res, 400, { error: invalidDeliver });
|
|
92
325
|
const task = await enqueueTask({
|
|
93
326
|
kind: sched?.recurring ? 'cron' : 'once',
|
|
94
|
-
spec,
|
|
327
|
+
spec: specInput.spec,
|
|
95
328
|
schedule: sched?.recurring ? sched.normalized : undefined,
|
|
96
|
-
model
|
|
329
|
+
model,
|
|
330
|
+
deliver,
|
|
97
331
|
runAt: sched?.runAt ?? Date.now(),
|
|
98
332
|
});
|
|
99
333
|
return send(res, 201, { task });
|
|
@@ -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('&', '&')
|
|
131
|
+
.replaceAll('<', '<')
|
|
132
|
+
.replaceAll('>', '>')
|
|
133
|
+
.replaceAll('"', '"')
|
|
134
|
+
.replaceAll("'", ''');
|
|
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
|
+
}
|