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.
- package/.env.example +161 -3
- package/CHANGELOG.md +148 -10
- package/README.md +255 -26
- package/README.th.md +95 -7
- package/dist/approval.js +13 -0
- package/dist/bin.js +3552 -155
- package/dist/brain-consolidate.js +335 -0
- package/dist/brain-context.js +262 -0
- package/dist/brain-doctor.js +318 -0
- package/dist/brain-eval.js +186 -0
- package/dist/brain-final.js +377 -0
- package/dist/brain-metrics.js +277 -0
- package/dist/brain-new.js +402 -0
- package/dist/brain-pack.js +210 -0
- package/dist/brain-repair.js +280 -0
- package/dist/brain-review.js +382 -0
- package/dist/brain.js +15 -1
- package/dist/brand.js +1 -1
- package/dist/cli-args.js +190 -0
- package/dist/cli-option-values.js +16 -0
- package/dist/clipboard.js +65 -0
- package/dist/commands.js +266 -27
- package/dist/compaction.js +96 -11
- package/dist/config.js +149 -33
- package/dist/context-compression.js +191 -0
- package/dist/context-pack.js +145 -0
- package/dist/cost.js +49 -15
- package/dist/dashboard/api-helpers.js +87 -0
- package/dist/dashboard/server.js +179 -0
- package/dist/dashboard/static/app.js +277 -0
- package/dist/dashboard/static/index.html +39 -0
- package/dist/dashboard/static/styles.css +85 -0
- package/dist/diff.js +10 -2
- package/dist/first-run.js +21 -0
- package/dist/gateway/auth.js +49 -9
- package/dist/gateway/bluebubbles.js +205 -0
- package/dist/gateway/config.js +929 -0
- package/dist/gateway/deliver.js +399 -0
- package/dist/gateway/discord.js +124 -0
- package/dist/gateway/doctor.js +456 -0
- package/dist/gateway/email.js +501 -0
- package/dist/gateway/googlechat.js +207 -0
- package/dist/gateway/homeassistant.js +256 -0
- package/dist/gateway/ledger.js +38 -1
- 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 +362 -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/hotkeys.js +21 -0
- package/dist/i18n/en.js +98 -0
- package/dist/i18n/index.js +19 -0
- package/dist/i18n/th.js +98 -0
- package/dist/i18n/types.js +1 -0
- package/dist/insights-args.js +55 -0
- package/dist/insights.js +86 -0
- package/dist/knowledge.js +55 -29
- package/dist/loop.js +157 -29
- package/dist/lsp/index.js +23 -5
- package/dist/mcp-hub.js +33 -0
- package/dist/mcp-registry.js +494 -0
- package/dist/mcp-risk.js +71 -0
- package/dist/mcp-server.js +1 -1
- package/dist/mcp.js +120 -10
- package/dist/memory-log.js +90 -0
- package/dist/memory-store.js +37 -1
- package/dist/memory.js +148 -37
- package/dist/model-picker.js +58 -0
- package/dist/orchestrate.js +51 -19
- package/dist/personality.js +58 -0
- package/dist/plan-handoff.js +17 -0
- package/dist/polyglot.js +162 -0
- package/dist/process-runner.js +96 -0
- package/dist/project-init.js +91 -0
- package/dist/project-registry.js +143 -0
- package/dist/project-scaffold.js +124 -0
- package/dist/prompt-size.js +155 -0
- package/dist/providers/codex-login.js +138 -0
- package/dist/providers/codex.js +89 -43
- package/dist/providers/keys.js +22 -1
- package/dist/providers/models.js +2 -2
- package/dist/providers/registry.js +14 -47
- package/dist/search/chunk.js +7 -8
- package/dist/search/cli.js +83 -0
- package/dist/search/embed-store.js +3 -0
- package/dist/search/embedding-config.js +22 -0
- package/dist/search/engine.js +2 -13
- package/dist/search/indexer.js +44 -1
- package/dist/search/store.js +23 -1
- package/dist/session-distill.js +84 -0
- package/dist/session.js +92 -16
- package/dist/skill-install.js +53 -13
- package/dist/skills.js +33 -0
- package/dist/slash-completion.js +155 -0
- package/dist/support-dump.js +206 -0
- package/dist/tool-catalog.js +59 -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 +10 -0
- package/dist/tools/list.js +19 -6
- package/dist/tools/permission.js +992 -12
- package/dist/tools/polyglot.js +126 -0
- package/dist/tools/read.js +16 -4
- package/dist/tools/sandbox.js +38 -13
- package/dist/tools/schedule.js +19 -3
- package/dist/tools/search.js +226 -15
- package/dist/tools/task.js +40 -9
- package/dist/tools/timeout.js +23 -3
- package/dist/tools/web-fetch-tool.js +33 -0
- package/dist/trust.js +11 -1
- package/dist/turn-retrieval.js +83 -0
- package/dist/ui/app.js +878 -32
- package/dist/ui/banner.js +78 -4
- package/dist/ui/history.js +37 -5
- package/dist/ui/markdown.js +122 -0
- package/dist/ui/mentions.js +3 -2
- package/dist/ui/overlay.js +496 -0
- package/dist/ui/queue.js +23 -0
- package/dist/ui/render.js +20 -1
- package/dist/ui/session-panel.js +115 -0
- package/dist/ui/setup-providers.js +40 -0
- package/dist/ui/setup.js +172 -46
- package/dist/ui/status.js +142 -0
- package/dist/ui/thinking-panel.js +36 -0
- package/dist/ui/tool-trail.js +97 -0
- package/dist/ui/transcript.js +26 -0
- package/dist/ui/useBusyElapsed.js +19 -0
- package/dist/ui/useEditor.js +144 -5
- package/dist/ui/useGitBranch.js +57 -0
- package/dist/update.js +56 -17
- package/dist/web-fetch.js +637 -0
- package/dist/web-surface.js +190 -0
- package/dist/worktree.js +175 -4
- package/package.json +5 -5
- 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 +19 -4
- package/second-brain/Projects/sanook-cli/_Index.md +30 -0
- package/second-brain/Projects/sanook-cli/context.md +35 -0
- package/second-brain/Projects/sanook-cli/current-state.md +32 -0
- package/second-brain/Projects/sanook-cli/overview.md +41 -0
- package/second-brain/Projects/sanook-cli/repo.md +34 -0
- package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +197 -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-hermes-tui-parity-map.md +129 -0
- package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
- package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
- package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
- package/second-brain/Research/_Index.md +8 -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 +14 -4
- 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 +6 -1
- package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
- package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
- package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
- package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -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/Templates/project-workspace/_Index.md +31 -0
- package/second-brain/Templates/project-workspace/context.md +28 -0
- package/second-brain/Templates/project-workspace/current-state.md +29 -0
- package/second-brain/Templates/project-workspace/overview.md +39 -0
- package/second-brain/Templates/project-workspace/repo.md +33 -0
- package/second-brain/Vault Structure Map.md +2 -1
- package/skills/structured-output-llm/SKILL.md +1 -1
package/dist/gateway/telegram.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { runAgent } from '../loop.js';
|
|
2
1
|
import { redactKey } from '../providers/keys.js';
|
|
2
|
+
import { runGatewayAgent } from './session.js';
|
|
3
3
|
// Telegram channel adapter — long-polling (ไม่ต้อง public URL, เหมาะ local 24/7 แบบ Hermes)
|
|
4
4
|
// ⚠ remote surface ที่รัน agent ได้ → security: REQUIRED allowlist (fail-closed) + private chat only +
|
|
5
5
|
// per-chat rate-limit + error ไม่ leak internal. ทุกอย่าง fail-closed (ค่า default = ปฏิเสธ)
|
|
@@ -13,12 +13,20 @@ async function getUpdates(token, offset, signal) {
|
|
|
13
13
|
const j = (await r.json());
|
|
14
14
|
return j.result ?? [];
|
|
15
15
|
}
|
|
16
|
-
async function
|
|
17
|
-
await fetch(api(token, 'sendMessage'), {
|
|
16
|
+
export async function sendTelegramMessage(token, chatId, text, threadId) {
|
|
17
|
+
const r = await fetch(api(token, 'sendMessage'), {
|
|
18
18
|
method: 'POST',
|
|
19
19
|
headers: { 'content-type': 'application/json' },
|
|
20
|
-
body: JSON.stringify({
|
|
21
|
-
|
|
20
|
+
body: JSON.stringify({
|
|
21
|
+
chat_id: chatId,
|
|
22
|
+
text: text.slice(0, 4096),
|
|
23
|
+
...(threadId == null ? {} : { message_thread_id: threadId }),
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
if (!r.ok)
|
|
27
|
+
throw new Error(`Telegram sendMessage ${r.status}`);
|
|
28
|
+
const body = (await r.json().catch(() => ({})));
|
|
29
|
+
return { chatId, messageId: body.result?.message_id };
|
|
22
30
|
}
|
|
23
31
|
/** allowlist — fail-closed: ว่าง = ปฏิเสธทุกคน (ต้องตั้ง TELEGRAM_ALLOWED_CHATS ชัดเจน) */
|
|
24
32
|
export function isAllowed(chatId, allowed) {
|
|
@@ -32,8 +40,10 @@ export function parseAllowedChats(raw) {
|
|
|
32
40
|
return [];
|
|
33
41
|
return raw
|
|
34
42
|
.split(',')
|
|
35
|
-
.map((s) =>
|
|
36
|
-
.filter((
|
|
43
|
+
.map((s) => s.trim())
|
|
44
|
+
.filter((s) => /^-?\d+$/.test(s))
|
|
45
|
+
.map(Number)
|
|
46
|
+
.filter((n) => Number.isSafeInteger(n));
|
|
37
47
|
}
|
|
38
48
|
/** start long-polling — คืน stop(). ไม่ start ถ้าไม่มี allowlist (fail-closed) */
|
|
39
49
|
export function startTelegram(opts) {
|
|
@@ -47,6 +57,16 @@ export function startTelegram(opts) {
|
|
|
47
57
|
const running = new Set(); // กัน flood: 1 chat = 1 งานพร้อมกัน
|
|
48
58
|
async function loop() {
|
|
49
59
|
let offset = 0;
|
|
60
|
+
// ข้าม backlog ตอนเริ่ม — ไม่งั้น bot replay คำสั่งเก่าที่ค้างไว้ (Telegram เก็บ update ~24h) ตอน start
|
|
61
|
+
// = รัน bash/แก้ไฟล์ตามคำสั่งเก่าโดยไม่ได้ตั้งใจ. offset=-1 → คืน update ล่าสุดตัวเดียว แล้วเลื่อน offset ข้ามไป
|
|
62
|
+
try {
|
|
63
|
+
const initial = await getUpdates(opts.token, -1, ctrl.signal);
|
|
64
|
+
if (initial.length)
|
|
65
|
+
offset = initial[initial.length - 1].update_id + 1;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* ดึง backlog ไม่ได้ → เริ่มที่ 0 (ดีกว่าไม่เริ่มเลย) */
|
|
69
|
+
}
|
|
50
70
|
while (!stopped) {
|
|
51
71
|
try {
|
|
52
72
|
const updates = await getUpdates(opts.token, offset, ctrl.signal);
|
|
@@ -63,33 +83,36 @@ export function startTelegram(opts) {
|
|
|
63
83
|
}
|
|
64
84
|
if (!isAllowed(chat.id, opts.allowedChatIds)) {
|
|
65
85
|
opts.onLog?.(`Telegram: ปฏิเสธ chat ${chat.id} (ไม่อยู่ใน allowlist)`);
|
|
66
|
-
await
|
|
86
|
+
await sendTelegramMessage(opts.token, chat.id, '⛔ ไม่ได้รับอนุญาตให้ใช้ bot นี้');
|
|
67
87
|
continue;
|
|
68
88
|
}
|
|
69
89
|
if (running.has(chat.id)) {
|
|
70
|
-
await
|
|
90
|
+
await sendTelegramMessage(opts.token, chat.id, '⏳ กำลังทำงานก่อนหน้าอยู่ รอสักครู่');
|
|
71
91
|
continue;
|
|
72
92
|
}
|
|
73
93
|
running.add(chat.id);
|
|
74
94
|
opts.onLog?.(`Telegram ${chat.id}: ${text.slice(0, 50)}`);
|
|
75
95
|
void (async () => {
|
|
76
96
|
try {
|
|
77
|
-
await
|
|
78
|
-
const
|
|
97
|
+
await sendTelegramMessage(opts.token, chat.id, '⏳ กำลังคิด…');
|
|
98
|
+
const out = await runGatewayAgent({
|
|
99
|
+
platform: 'telegram',
|
|
100
|
+
target: String(chat.id),
|
|
79
101
|
model: opts.model,
|
|
80
102
|
prompt: text,
|
|
81
|
-
|
|
103
|
+
userText: text,
|
|
82
104
|
budgetUsd: opts.budgetUsd,
|
|
83
105
|
// remote surface: default ask-mode + ไม่มี approve fn → mutate tools (bash/write/edit/MCP-write)
|
|
84
106
|
// ถูกปฏิเสธอัตโนมัติ (single-factor chat-id ไม่พอจะให้ RCE). opt-in: TELEGRAM_ALLOW_WRITE=1
|
|
85
|
-
permissionMode:
|
|
107
|
+
permissionMode: opts.allowWrite === true ? 'auto' : 'ask',
|
|
86
108
|
});
|
|
87
|
-
|
|
109
|
+
if (!out.suppressDelivery)
|
|
110
|
+
await sendTelegramMessage(opts.token, chat.id, out.text || '(ไม่มีผลลัพธ์)');
|
|
88
111
|
}
|
|
89
112
|
catch (e) {
|
|
90
113
|
// ไม่ส่ง internal detail ให้ remote — log ฝั่ง server เท่านั้น
|
|
91
114
|
opts.onLog?.(`Telegram run error (${chat.id}): ${redactKey(e.message)}`);
|
|
92
|
-
await
|
|
115
|
+
await sendTelegramMessage(opts.token, chat.id, 'เกิดข้อผิดพลาดภายใน');
|
|
93
116
|
}
|
|
94
117
|
finally {
|
|
95
118
|
running.delete(chat.id);
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { redactKey } from '../providers/keys.js';
|
|
3
|
+
import { deliverToTarget } from './deliver.js';
|
|
4
|
+
import { runGatewayAgent } from './session.js';
|
|
5
|
+
const INSECURE_NO_AUTH = 'INSECURE_NO_AUTH';
|
|
6
|
+
const RAW_LIMIT = 4000;
|
|
7
|
+
const VALUE_LIMIT = 2000;
|
|
8
|
+
const seenDeliveries = new Map();
|
|
9
|
+
const rateWindows = new Map();
|
|
10
|
+
export function isValidWebhookRouteName(name) {
|
|
11
|
+
return /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name);
|
|
12
|
+
}
|
|
13
|
+
export function generateWebhookSecret() {
|
|
14
|
+
return randomBytes(24).toString('hex');
|
|
15
|
+
}
|
|
16
|
+
function firstHeader(headers, name) {
|
|
17
|
+
const value = headers[name.toLowerCase()];
|
|
18
|
+
return Array.isArray(value) ? value[0] : value;
|
|
19
|
+
}
|
|
20
|
+
function safeCompare(a, b) {
|
|
21
|
+
const left = Buffer.from(a);
|
|
22
|
+
const right = Buffer.from(b);
|
|
23
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
24
|
+
}
|
|
25
|
+
function hmacHex(secret, rawBody) {
|
|
26
|
+
return createHmac('sha256', secret).update(rawBody).digest('hex');
|
|
27
|
+
}
|
|
28
|
+
export function verifyWebhookSignature(secret, rawBody, headers) {
|
|
29
|
+
if (!secret)
|
|
30
|
+
return { ok: false, kind: 'none' };
|
|
31
|
+
if (secret === INSECURE_NO_AUTH)
|
|
32
|
+
return { ok: true, kind: 'insecure' };
|
|
33
|
+
const github = firstHeader(headers, 'x-hub-signature-256');
|
|
34
|
+
if (github?.startsWith('sha256=')) {
|
|
35
|
+
return { ok: safeCompare(github, `sha256=${hmacHex(secret, rawBody)}`), kind: 'github' };
|
|
36
|
+
}
|
|
37
|
+
const gitlab = firstHeader(headers, 'x-gitlab-token');
|
|
38
|
+
if (gitlab)
|
|
39
|
+
return { ok: safeCompare(gitlab, secret), kind: 'gitlab' };
|
|
40
|
+
const generic = firstHeader(headers, 'x-webhook-signature') ?? firstHeader(headers, 'x-sanook-signature');
|
|
41
|
+
if (generic) {
|
|
42
|
+
const expected = hmacHex(secret, rawBody);
|
|
43
|
+
const cleaned = generic.startsWith('sha256=') ? generic.slice('sha256='.length) : generic;
|
|
44
|
+
return { ok: safeCompare(cleaned, expected), kind: 'generic' };
|
|
45
|
+
}
|
|
46
|
+
return { ok: false, kind: 'none' };
|
|
47
|
+
}
|
|
48
|
+
function truncate(text, limit) {
|
|
49
|
+
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
|
|
50
|
+
}
|
|
51
|
+
function getPath(payload, path) {
|
|
52
|
+
return path.split('.').reduce((cur, part) => {
|
|
53
|
+
if (cur && typeof cur === 'object' && part in cur)
|
|
54
|
+
return cur[part];
|
|
55
|
+
return undefined;
|
|
56
|
+
}, payload);
|
|
57
|
+
}
|
|
58
|
+
function stringifyTemplateValue(value) {
|
|
59
|
+
if (value === undefined)
|
|
60
|
+
return '';
|
|
61
|
+
if (value === null)
|
|
62
|
+
return 'null';
|
|
63
|
+
if (typeof value === 'string')
|
|
64
|
+
return truncate(value, VALUE_LIMIT);
|
|
65
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
66
|
+
return String(value);
|
|
67
|
+
return truncate(JSON.stringify(value, null, 2), VALUE_LIMIT);
|
|
68
|
+
}
|
|
69
|
+
export function renderWebhookTemplate(template, payload) {
|
|
70
|
+
const fallback = `Webhook event:\n${truncate(JSON.stringify(payload, null, 2), RAW_LIMIT)}`;
|
|
71
|
+
const source = template?.trim() ? template : fallback;
|
|
72
|
+
return source.replace(/\{([A-Za-z0-9_.-]+|__raw__)\}/g, (match, key) => {
|
|
73
|
+
if (key === '__raw__')
|
|
74
|
+
return truncate(JSON.stringify(payload, null, 2), RAW_LIMIT);
|
|
75
|
+
const value = getPath(payload, key);
|
|
76
|
+
return value === undefined ? match : stringifyTemplateValue(value);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
export function webhookEventType(headers, payload) {
|
|
80
|
+
const fromHeader = firstHeader(headers, 'x-github-event') ?? firstHeader(headers, 'x-gitlab-event') ?? firstHeader(headers, 'x-event-type');
|
|
81
|
+
if (fromHeader?.trim())
|
|
82
|
+
return fromHeader.trim();
|
|
83
|
+
if (payload && typeof payload === 'object') {
|
|
84
|
+
const record = payload;
|
|
85
|
+
if (typeof record.event_type === 'string')
|
|
86
|
+
return record.event_type;
|
|
87
|
+
if (typeof record.object_kind === 'string')
|
|
88
|
+
return record.object_kind;
|
|
89
|
+
if (typeof record.type === 'string')
|
|
90
|
+
return record.type;
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
function deliveryId(routeName, headers) {
|
|
95
|
+
const id = firstHeader(headers, 'x-github-delivery') ?? firstHeader(headers, 'x-request-id') ?? firstHeader(headers, 'x-webhook-delivery');
|
|
96
|
+
return id ? `${routeName}:${id}` : undefined;
|
|
97
|
+
}
|
|
98
|
+
function pruneSeenDeliveries(now) {
|
|
99
|
+
for (const [key, seenAt] of seenDeliveries) {
|
|
100
|
+
if (now - seenAt > 60 * 60 * 1000)
|
|
101
|
+
seenDeliveries.delete(key);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function hasSeenDelivery(routeName, headers, now = Date.now()) {
|
|
105
|
+
const id = deliveryId(routeName, headers);
|
|
106
|
+
if (!id)
|
|
107
|
+
return false;
|
|
108
|
+
pruneSeenDeliveries(now);
|
|
109
|
+
return seenDeliveries.has(id);
|
|
110
|
+
}
|
|
111
|
+
function beginDelivery(routeName, headers, now = Date.now()) {
|
|
112
|
+
const id = deliveryId(routeName, headers);
|
|
113
|
+
if (!id)
|
|
114
|
+
return undefined;
|
|
115
|
+
pruneSeenDeliveries(now);
|
|
116
|
+
seenDeliveries.set(id, now);
|
|
117
|
+
return id;
|
|
118
|
+
}
|
|
119
|
+
function completeDelivery(id, now = Date.now()) {
|
|
120
|
+
if (!id)
|
|
121
|
+
return;
|
|
122
|
+
pruneSeenDeliveries(now);
|
|
123
|
+
seenDeliveries.set(id, now);
|
|
124
|
+
}
|
|
125
|
+
function forgetDelivery(id) {
|
|
126
|
+
if (id)
|
|
127
|
+
seenDeliveries.delete(id);
|
|
128
|
+
}
|
|
129
|
+
function checkRateLimit(route, config, now = Date.now()) {
|
|
130
|
+
const limit = route.rateLimitPerMinute ?? config.rateLimitPerMinute;
|
|
131
|
+
if (!Number.isInteger(limit) || limit <= 0)
|
|
132
|
+
return true;
|
|
133
|
+
const key = route.name;
|
|
134
|
+
const current = rateWindows.get(key);
|
|
135
|
+
if (!current || now - current.start >= 60_000) {
|
|
136
|
+
rateWindows.set(key, { start: now, count: 1 });
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
current.count += 1;
|
|
140
|
+
return current.count <= limit;
|
|
141
|
+
}
|
|
142
|
+
export function routeToConfig(name, route) {
|
|
143
|
+
return {
|
|
144
|
+
name,
|
|
145
|
+
events: route.events ?? [],
|
|
146
|
+
secret: route.secret,
|
|
147
|
+
prompt: route.prompt,
|
|
148
|
+
deliver: route.deliver?.trim() || 'log',
|
|
149
|
+
deliverOnly: route.deliverOnly === true,
|
|
150
|
+
description: route.description,
|
|
151
|
+
rateLimitPerMinute: route.rateLimitPerMinute,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
export async function handleWebhookRequest(opts) {
|
|
155
|
+
if (!opts.config.enabled)
|
|
156
|
+
return { status: 503, body: { error: 'webhooks_disabled' } };
|
|
157
|
+
const route = opts.config.routes[opts.routeName];
|
|
158
|
+
if (!route)
|
|
159
|
+
return { status: 404, body: { error: 'unknown_route' } };
|
|
160
|
+
const secret = route.secret || opts.config.secret;
|
|
161
|
+
const sig = verifyWebhookSignature(secret, opts.rawBody, opts.headers);
|
|
162
|
+
if (!sig.ok)
|
|
163
|
+
return { status: 401, body: { error: 'invalid_signature', route: route.name } };
|
|
164
|
+
let payload;
|
|
165
|
+
try {
|
|
166
|
+
payload = opts.rawBody.trim() ? JSON.parse(opts.rawBody) : {};
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return { status: 400, body: { error: 'invalid_json', route: route.name } };
|
|
170
|
+
}
|
|
171
|
+
const event = webhookEventType(opts.headers, payload);
|
|
172
|
+
if (route.events.length && (!event || !route.events.includes(event))) {
|
|
173
|
+
return { status: 200, body: { status: 'ignored', route: route.name, event: event ?? null } };
|
|
174
|
+
}
|
|
175
|
+
if (route.deliverOnly && route.deliver === 'log') {
|
|
176
|
+
return { status: 400, body: { error: 'deliver_only_requires_target', route: route.name } };
|
|
177
|
+
}
|
|
178
|
+
if (hasSeenDelivery(route.name, opts.headers))
|
|
179
|
+
return { status: 200, body: { status: 'duplicate', route: route.name } };
|
|
180
|
+
if (!checkRateLimit(route, opts.config))
|
|
181
|
+
return { status: 429, body: { error: 'rate_limited', route: route.name } };
|
|
182
|
+
const trackedDeliveryId = beginDelivery(route.name, opts.headers);
|
|
183
|
+
const rendered = renderWebhookTemplate(route.prompt, payload);
|
|
184
|
+
if (route.deliverOnly) {
|
|
185
|
+
try {
|
|
186
|
+
const delivery = await deliverToTarget(route.deliver, rendered, { subject: `Webhook ${route.name}` });
|
|
187
|
+
completeDelivery(trackedDeliveryId);
|
|
188
|
+
return { status: 200, body: { status: 'delivered', route: route.name, target: delivery.target } };
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
forgetDelivery(trackedDeliveryId);
|
|
192
|
+
opts.onLog?.(`Webhook delivery error (${route.name}): ${redactKey(e.message)}`);
|
|
193
|
+
return { status: 502, body: { error: 'delivery_failed', route: route.name } };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const out = await runGatewayAgent({
|
|
198
|
+
platform: 'webhook',
|
|
199
|
+
target: route.name,
|
|
200
|
+
model: opts.model,
|
|
201
|
+
prompt: rendered,
|
|
202
|
+
userText: rendered,
|
|
203
|
+
budgetUsd: opts.budgetUsd,
|
|
204
|
+
permissionMode: opts.permissionMode ?? 'ask',
|
|
205
|
+
});
|
|
206
|
+
if (out.suppressDelivery || route.deliver === 'log') {
|
|
207
|
+
opts.onLog?.(`Webhook ${route.name}: ${truncate(out.text || '(ไม่มีผลลัพธ์)', 500)}`);
|
|
208
|
+
completeDelivery(trackedDeliveryId);
|
|
209
|
+
return { status: 200, body: { status: 'processed', route: route.name, delivered: false } };
|
|
210
|
+
}
|
|
211
|
+
const delivery = await deliverToTarget(route.deliver, out.text || '(ไม่มีผลลัพธ์)', { subject: `Webhook ${route.name}` });
|
|
212
|
+
completeDelivery(trackedDeliveryId);
|
|
213
|
+
return { status: 200, body: { status: 'delivered', route: route.name, target: delivery.target } };
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
forgetDelivery(trackedDeliveryId);
|
|
217
|
+
opts.onLog?.(`Webhook run error (${route.name}): ${redactKey(e.message)}`);
|
|
218
|
+
return { status: 500, body: { error: 'agent_failed', route: route.name } };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { redactKey } from '../providers/keys.js';
|
|
3
|
+
import { runGatewayAgent } from './session.js';
|
|
4
|
+
const WHATSAPP_GRAPH_BASE = 'https://graph.facebook.com';
|
|
5
|
+
const WHATSAPP_TEXT_LIMIT = 4096;
|
|
6
|
+
const seenMessageIds = new Set();
|
|
7
|
+
const runningTargets = new Set();
|
|
8
|
+
export function normalizeWhatsAppId(raw) {
|
|
9
|
+
const trimmed = raw?.trim();
|
|
10
|
+
if (!trimmed)
|
|
11
|
+
return undefined;
|
|
12
|
+
const compact = trimmed.replace(/[\s()+.-]+/g, '');
|
|
13
|
+
return /^\d+$/.test(compact) ? compact : undefined;
|
|
14
|
+
}
|
|
15
|
+
export function redactWhatsAppId(raw) {
|
|
16
|
+
const normalized = normalizeWhatsAppId(raw);
|
|
17
|
+
if (!normalized)
|
|
18
|
+
return '(not set)';
|
|
19
|
+
if (normalized.length <= 6)
|
|
20
|
+
return '<redacted>';
|
|
21
|
+
return `${normalized.slice(0, 4)}…${normalized.slice(-4)}`;
|
|
22
|
+
}
|
|
23
|
+
export function whatsAppMessagesUrl(config) {
|
|
24
|
+
const apiVersion = (config.apiVersion || 'v20.0').replace(/^\/+|\/+$/g, '');
|
|
25
|
+
const phoneNumberId = config.phoneNumberId?.trim();
|
|
26
|
+
if (!phoneNumberId)
|
|
27
|
+
throw new Error('WhatsApp Cloud phone number id ว่าง');
|
|
28
|
+
return `${WHATSAPP_GRAPH_BASE}/${apiVersion}/${encodeURIComponent(phoneNumberId)}/messages`;
|
|
29
|
+
}
|
|
30
|
+
export function whatsAppPlainText(raw) {
|
|
31
|
+
return raw
|
|
32
|
+
.replace(/\r\n/g, '\n')
|
|
33
|
+
.replace(/```[a-zA-Z0-9_-]*\n?/g, '')
|
|
34
|
+
.replace(/```/g, '')
|
|
35
|
+
.replace(/\[([^\]]+)]\(([^)]+)\)/g, '$1 ($2)')
|
|
36
|
+
.replace(/^#{1,6}\s+(.+)$/gm, '*$1*')
|
|
37
|
+
.replace(/\*\*([^*]+)\*\*/g, '*$1*')
|
|
38
|
+
.replace(/__([^_]+)__/g, '_$1_')
|
|
39
|
+
.replace(/~~([^~]+)~~/g, '~$1~')
|
|
40
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
41
|
+
.trim();
|
|
42
|
+
}
|
|
43
|
+
export function splitWhatsAppText(raw, limit = WHATSAPP_TEXT_LIMIT) {
|
|
44
|
+
let remaining = whatsAppPlainText(raw) || '(ไม่มีผลลัพธ์)';
|
|
45
|
+
const chunks = [];
|
|
46
|
+
while (remaining.length > limit) {
|
|
47
|
+
const window = remaining.slice(0, limit + 1);
|
|
48
|
+
let cut = window.lastIndexOf('\n');
|
|
49
|
+
if (cut < Math.floor(limit * 0.4))
|
|
50
|
+
cut = window.lastIndexOf(' ');
|
|
51
|
+
if (cut < Math.floor(limit * 0.4))
|
|
52
|
+
cut = limit;
|
|
53
|
+
chunks.push(remaining.slice(0, cut).trim());
|
|
54
|
+
remaining = remaining.slice(cut).trimStart();
|
|
55
|
+
}
|
|
56
|
+
if (remaining)
|
|
57
|
+
chunks.push(remaining);
|
|
58
|
+
return chunks.length ? chunks : ['(ไม่มีผลลัพธ์)'];
|
|
59
|
+
}
|
|
60
|
+
export async function sendWhatsAppMessage(config, to, text) {
|
|
61
|
+
const phoneNumberId = config.phoneNumberId?.trim();
|
|
62
|
+
const accessToken = config.accessToken?.trim();
|
|
63
|
+
const recipient = normalizeWhatsAppId(to);
|
|
64
|
+
if (!phoneNumberId || !accessToken)
|
|
65
|
+
throw new Error('WhatsApp Cloud config ต้องมี phoneNumberId และ accessToken');
|
|
66
|
+
if (!recipient)
|
|
67
|
+
throw new Error('WhatsApp recipient ว่าง');
|
|
68
|
+
const chunks = splitWhatsAppText(text);
|
|
69
|
+
const messageIds = [];
|
|
70
|
+
for (const body of chunks) {
|
|
71
|
+
const r = await fetch(whatsAppMessagesUrl(config), {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
authorization: `Bearer ${accessToken}`,
|
|
75
|
+
'content-type': 'application/json',
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify({
|
|
78
|
+
messaging_product: 'whatsapp',
|
|
79
|
+
recipient_type: 'individual',
|
|
80
|
+
to: recipient,
|
|
81
|
+
type: 'text',
|
|
82
|
+
text: { preview_url: false, body },
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
if (!r.ok) {
|
|
86
|
+
const detail = await r.text().catch(() => '');
|
|
87
|
+
throw new Error(`WhatsApp Cloud send ${r.status}${detail ? `: ${redactKey(detail).slice(0, 200)}` : ''}`);
|
|
88
|
+
}
|
|
89
|
+
const parsed = (await r.json().catch(() => ({})));
|
|
90
|
+
for (const message of parsed.messages ?? []) {
|
|
91
|
+
if (message.id)
|
|
92
|
+
messageIds.push(message.id);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { to: recipient, messageCount: chunks.length, messageIds };
|
|
96
|
+
}
|
|
97
|
+
export function verifyWhatsAppSignature(appSecret, rawBody, signature) {
|
|
98
|
+
const secret = appSecret?.trim();
|
|
99
|
+
const header = signature?.trim();
|
|
100
|
+
if (!secret || !header?.startsWith('sha256='))
|
|
101
|
+
return false;
|
|
102
|
+
const expected = `sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`;
|
|
103
|
+
const a = Buffer.from(header);
|
|
104
|
+
const b = Buffer.from(expected);
|
|
105
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
106
|
+
}
|
|
107
|
+
export function handleWhatsAppChallenge(config, params) {
|
|
108
|
+
const mode = params.get('hub.mode');
|
|
109
|
+
const token = params.get('hub.verify_token');
|
|
110
|
+
const challenge = params.get('hub.challenge') ?? '';
|
|
111
|
+
if (mode !== 'subscribe' || !config.verifyToken || token !== config.verifyToken) {
|
|
112
|
+
return { status: 403, body: 'forbidden', contentType: 'text/plain; charset=utf-8' };
|
|
113
|
+
}
|
|
114
|
+
return { status: 200, body: challenge, contentType: 'text/plain; charset=utf-8' };
|
|
115
|
+
}
|
|
116
|
+
function parseWhatsAppPayload(rawBody) {
|
|
117
|
+
const parsed = JSON.parse(rawBody);
|
|
118
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
119
|
+
}
|
|
120
|
+
export function extractWhatsAppTextEvents(payload) {
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const entry of payload.entry ?? []) {
|
|
123
|
+
for (const change of entry.changes ?? []) {
|
|
124
|
+
const contacts = new Map();
|
|
125
|
+
for (const contact of change.value?.contacts ?? []) {
|
|
126
|
+
if (contact.wa_id)
|
|
127
|
+
contacts.set(contact.wa_id, contact.profile?.name ?? '');
|
|
128
|
+
}
|
|
129
|
+
for (const message of change.value?.messages ?? []) {
|
|
130
|
+
const from = normalizeWhatsAppId(message.from);
|
|
131
|
+
const text = message.type === 'text' ? message.text?.body?.trim() : undefined;
|
|
132
|
+
if (!from || !text)
|
|
133
|
+
continue;
|
|
134
|
+
out.push({
|
|
135
|
+
from,
|
|
136
|
+
text,
|
|
137
|
+
messageId: message.id,
|
|
138
|
+
timestamp: message.timestamp,
|
|
139
|
+
profileName: contacts.get(from),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
export function isAllowedWhatsAppSender(config, from) {
|
|
147
|
+
if (config.allowAllUsers)
|
|
148
|
+
return true;
|
|
149
|
+
const sender = normalizeWhatsAppId(from);
|
|
150
|
+
if (!sender)
|
|
151
|
+
return false;
|
|
152
|
+
if (sender === normalizeWhatsAppId(config.homeChannel))
|
|
153
|
+
return true;
|
|
154
|
+
return config.allowedUsers.map(normalizeWhatsAppId).includes(sender);
|
|
155
|
+
}
|
|
156
|
+
function rememberMessageId(id) {
|
|
157
|
+
if (!id)
|
|
158
|
+
return true;
|
|
159
|
+
if (seenMessageIds.has(id))
|
|
160
|
+
return false;
|
|
161
|
+
seenMessageIds.add(id);
|
|
162
|
+
if (seenMessageIds.size > 5000) {
|
|
163
|
+
const first = seenMessageIds.values().next().value;
|
|
164
|
+
if (first)
|
|
165
|
+
seenMessageIds.delete(first);
|
|
166
|
+
}
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
function whatsAppPrompt(event) {
|
|
170
|
+
const name = event.profileName?.trim();
|
|
171
|
+
return [`WhatsApp from ${name ? `${name} ` : ''}${redactWhatsAppId(event.from)}:`, event.text].join('\n');
|
|
172
|
+
}
|
|
173
|
+
export async function handleWhatsAppWebhook(opts) {
|
|
174
|
+
if (!opts.config.phoneNumberId || !opts.config.accessToken)
|
|
175
|
+
return { status: 503, body: { error: 'whatsapp_not_configured' } };
|
|
176
|
+
if (!opts.config.appSecret)
|
|
177
|
+
return { status: 503, body: { error: 'whatsapp_app_secret_required' } };
|
|
178
|
+
if (!verifyWhatsAppSignature(opts.config.appSecret, opts.rawBody, opts.signature)) {
|
|
179
|
+
return { status: 401, body: { error: 'invalid_signature' } };
|
|
180
|
+
}
|
|
181
|
+
let payload;
|
|
182
|
+
try {
|
|
183
|
+
payload = parseWhatsAppPayload(opts.rawBody);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return { status: 400, body: { error: 'invalid_json' } };
|
|
187
|
+
}
|
|
188
|
+
let accepted = 0;
|
|
189
|
+
let ignored = 0;
|
|
190
|
+
const running = opts.runningTargets ?? runningTargets;
|
|
191
|
+
for (const event of extractWhatsAppTextEvents(payload)) {
|
|
192
|
+
if (!rememberMessageId(event.messageId)) {
|
|
193
|
+
ignored += 1;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (!isAllowedWhatsAppSender(opts.config, event.from)) {
|
|
197
|
+
ignored += 1;
|
|
198
|
+
opts.onLog?.(`WhatsApp: ปฏิเสธ sender ${redactWhatsAppId(event.from)} (ไม่อยู่ใน allowlist)`);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (running.has(event.from)) {
|
|
202
|
+
ignored += 1;
|
|
203
|
+
await sendWhatsAppMessage(opts.config, event.from, 'กำลังทำงานก่อนหน้าอยู่ รอสักครู่').catch(() => { });
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
accepted += 1;
|
|
207
|
+
running.add(event.from);
|
|
208
|
+
try {
|
|
209
|
+
const result = await runGatewayAgent({
|
|
210
|
+
platform: 'whatsapp',
|
|
211
|
+
target: event.from,
|
|
212
|
+
model: opts.model,
|
|
213
|
+
prompt: whatsAppPrompt(event),
|
|
214
|
+
userText: event.text,
|
|
215
|
+
budgetUsd: opts.budgetUsd,
|
|
216
|
+
permissionMode: opts.permissionMode ?? 'ask',
|
|
217
|
+
});
|
|
218
|
+
if (!result.suppressDelivery)
|
|
219
|
+
await sendWhatsAppMessage(opts.config, event.from, result.text || '(ไม่มีผลลัพธ์)');
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
opts.onLog?.(`WhatsApp run error (${redactWhatsAppId(event.from)}): ${redactKey(e.message)}`);
|
|
223
|
+
await sendWhatsAppMessage(opts.config, event.from, 'เกิดข้อผิดพลาดภายใน').catch(() => { });
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
running.delete(event.from);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { status: 200, body: { ok: true, accepted, ignored } };
|
|
230
|
+
}
|
package/dist/hooks.js
CHANGED
|
@@ -2,7 +2,12 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { appHomePath, BRAND_ENV, envFlag } from './brand.js';
|
|
4
4
|
import { hasUntrustedProjectConfig, projectConfigPathIfTrusted, projectRoot } from './trust.js';
|
|
5
|
-
|
|
5
|
+
// รวม Windows-critical — hooks รันด้วย shell:true (cmd.exe ผ่าน ComSpec); ขาด SystemRoot/ComSpec/PATHEXT
|
|
6
|
+
// = cmd.exe spawn ไม่ขึ้น/หา .cmd ไม่เจอ → hooks พังทั้งหมดบน Windows
|
|
7
|
+
const SAFE_ENV_KEYS = [
|
|
8
|
+
'PATH', 'HOME', 'TMPDIR', 'TEMP', 'TMP', 'LANG', 'LC_ALL', 'USER', 'SHELL', 'TERM', 'NODE_PATH', 'NVM_DIR',
|
|
9
|
+
'APPDATA', 'LOCALAPPDATA', 'USERPROFILE', 'SystemRoot', 'SystemDrive', 'windir', 'PATHEXT', 'ComSpec',
|
|
10
|
+
];
|
|
6
11
|
function safeEnv() {
|
|
7
12
|
const out = {};
|
|
8
13
|
for (const k of SAFE_ENV_KEYS) {
|
|
@@ -13,7 +18,13 @@ function safeEnv() {
|
|
|
13
18
|
return out;
|
|
14
19
|
}
|
|
15
20
|
async function readHooksFile(path, merged) {
|
|
16
|
-
|
|
21
|
+
let cfg;
|
|
22
|
+
try {
|
|
23
|
+
cfg = JSON.parse(await readFile(path, 'utf8'));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return; // ไม่มีไฟล์ หรือ JSON พัง → ข้าม (ไม่งั้น hooks.json เสียใน trusted project ทำ agent run crash ทั้งรอบ)
|
|
27
|
+
}
|
|
17
28
|
const valid = (h) => Boolean(h &&
|
|
18
29
|
typeof h === 'object' &&
|
|
19
30
|
typeof h.matcher === 'string' &&
|
package/dist/hotkeys.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const HOTKEYS = [
|
|
2
|
+
['Ctrl+C', 'clear draft / interrupt running turn / exit when input is empty'],
|
|
3
|
+
['Esc', 'stop running turn and clear queued prompts'],
|
|
4
|
+
['↑/↓', 'prompt history'],
|
|
5
|
+
['Ctrl+A/E', 'start / end of line'],
|
|
6
|
+
['Ctrl+U/K', 'delete to start / end'],
|
|
7
|
+
['Ctrl+W', 'delete previous word'],
|
|
8
|
+
['Alt+Enter', 'insert newline'],
|
|
9
|
+
['\\+Enter', 'multi-line continuation fallback'],
|
|
10
|
+
['paste 5+ lines', 'collapse into a readable token, expand before submit'],
|
|
11
|
+
['type while busy + Enter', 'queue the next prompt'],
|
|
12
|
+
['busy ↑/↓ + Ctrl+X', 'select and delete queued prompts'],
|
|
13
|
+
['Ctrl+T', 'toggle tool trail compact / expanded'],
|
|
14
|
+
['@file', 'inline a file or attach an image'],
|
|
15
|
+
['/model <spec>', 'switch model'],
|
|
16
|
+
['/diff /undo /rewind', 'inspect, stash, or rewind file changes'],
|
|
17
|
+
];
|
|
18
|
+
export function formatHotkeys() {
|
|
19
|
+
const width = HOTKEYS.reduce((max, [key]) => Math.max(max, key.length), 0);
|
|
20
|
+
return ['hotkeys:', ...HOTKEYS.map(([key, help]) => ` ${key.padEnd(width)} ${help}`)].join('\n');
|
|
21
|
+
}
|