sanook-cli 0.5.2 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -2
- package/README.md +15 -3
- package/README.th.md +8 -1
- package/dist/approval.js +7 -0
- package/dist/bin.js +623 -56
- package/dist/brain-consolidate.js +335 -0
- package/dist/brain-context.js +42 -3
- package/dist/brain-final.js +15 -9
- 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.js +3 -0
- package/dist/cli-args.js +47 -9
- package/dist/cli-option-values.js +1 -1
- package/dist/clipboard.js +65 -0
- package/dist/commands.js +94 -14
- package/dist/config.js +31 -5
- package/dist/context-pack.js +145 -0
- 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/gateway/auth.js +14 -3
- package/dist/gateway/deliver.js +45 -3
- package/dist/gateway/doctor.js +456 -0
- package/dist/gateway/email.js +30 -1
- package/dist/gateway/ledger.js +20 -1
- package/dist/gateway/session.js +30 -11
- 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 +24 -4
- package/dist/knowledge.js +55 -29
- package/dist/loop.js +34 -5
- package/dist/mcp-hub.js +33 -0
- package/dist/mcp-registry.js +153 -9
- package/dist/mcp-risk.js +71 -0
- package/dist/mcp.js +77 -5
- package/dist/memory-log.js +90 -0
- package/dist/memory-store.js +37 -1
- package/dist/memory.js +51 -7
- package/dist/model-picker.js +58 -0
- package/dist/orchestrate.js +7 -5
- 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 +20 -8
- package/dist/providers/keys.js +21 -0
- package/dist/providers/models.js +1 -1
- package/dist/search/cli.js +9 -1
- package/dist/search/embedding-config.js +22 -0
- package/dist/search/engine.js +2 -13
- package/dist/search/indexer.js +10 -10
- package/dist/session-distill.js +84 -0
- package/dist/session.js +1 -11
- package/dist/skill-install.js +24 -1
- package/dist/skills.js +33 -0
- package/dist/slash-completion.js +155 -0
- package/dist/support-dump.js +31 -0
- package/dist/tool-catalog.js +59 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/permission.js +82 -16
- package/dist/tools/polyglot.js +126 -0
- package/dist/tools/sandbox.js +38 -13
- package/dist/tools/search.js +9 -2
- package/dist/tools/task.js +22 -2
- package/dist/tools/timeout.js +7 -5
- package/dist/tools/web-fetch-tool.js +33 -0
- package/dist/turn-retrieval.js +83 -0
- package/dist/ui/app.js +835 -29
- package/dist/ui/banner.js +78 -4
- package/dist/ui/markdown.js +122 -0
- 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 +163 -50
- 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 +32 -6
- package/dist/web-fetch.js +637 -0
- package/dist/web-surface.js +190 -0
- package/package.json +2 -2
- package/second-brain/Projects/_Index.md +17 -4
- package/second-brain/Projects/sanook-cli/_Index.md +7 -3
- 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 +52 -11
- package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -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 +2 -0
- package/second-brain/Shared/Operating-State/current-state.md +14 -23
- package/second-brain/Shared/Tech-Standards/_Index.md +2 -0
- package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
- package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -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/dist/commands.js
CHANGED
|
@@ -6,16 +6,25 @@ import { parseFrontmatter } from './skills.js';
|
|
|
6
6
|
import { projectConfigPathIfTrusted } from './trust.js';
|
|
7
7
|
import { normalizePersonalityName, personalityListText } from './personality.js';
|
|
8
8
|
import { parseInsightsArgs } from './insights-args.js';
|
|
9
|
-
|
|
9
|
+
import { formatHotkeys } from './hotkeys.js';
|
|
10
|
+
import { formatToolCatalog } from './tool-catalog.js';
|
|
11
|
+
export const HELP_TEXT = `คำสั่ง:
|
|
10
12
|
/help แสดงคำสั่งทั้งหมด
|
|
11
13
|
/new, /reset เริ่มบทสนทนาใหม่
|
|
12
14
|
/status ดูสถานะ session ปัจจุบัน
|
|
13
|
-
/model [spec] ดู/เปลี่ยน model
|
|
15
|
+
/model [spec] ดู/เปลี่ยน model — /model เปิด picker 2 ขั้น (provider → model)
|
|
16
|
+
/setup ดูขั้นตอน setup wizard (model · agent · tools · gateway · brain)
|
|
17
|
+
/dashboard เปิด Sanook Dashboard (local web UI)
|
|
14
18
|
/personality [name]
|
|
15
19
|
ดู/ตั้ง personality overlay
|
|
20
|
+
/details [thinking|tools] [hidden|collapsed|expanded]
|
|
21
|
+
คุมแผง thinking/tool trail แบบ Hermes-style
|
|
16
22
|
/platforms ดู providers + messaging platforms ที่รองรับ
|
|
17
23
|
/tools ดู tools ที่ agent ใช้ได้
|
|
18
|
-
/
|
|
24
|
+
/mcp เปิด MCP Hub overlay
|
|
25
|
+
/skills เปิด Skills Hub overlay (จัดการ: ${BRAND.cliName} skill list)
|
|
26
|
+
/sessions เปิด Session Switcher overlay · /trail พับ/ขยาย tool trail
|
|
27
|
+
/tasks ดู background sub-agents (task_spawn)
|
|
19
28
|
/diff ดู git diff (สิ่งที่ agent แก้ในรอบนี้)
|
|
20
29
|
/retry รัน prompt ล่าสุดอีกครั้ง
|
|
21
30
|
/stop หยุด turn ที่กำลังรัน
|
|
@@ -24,6 +33,8 @@ const HELP_TEXT = `คำสั่ง:
|
|
|
24
33
|
/cost, /usage ดู token + cost รอบล่าสุด
|
|
25
34
|
/insights [--days N] [--all]
|
|
26
35
|
ดู usage/session insights ในเครื่อง
|
|
36
|
+
/hotkeys เปิด overlay คีย์ลัดใน REPL
|
|
37
|
+
/copy [last] copy คำตอบ assistant ล่าสุดไป clipboard/OSC52
|
|
27
38
|
↑/↓ ประวัติ · @ไฟล์ แนบ context/รูป · \\ ลงท้าย = บรรทัดใหม่
|
|
28
39
|
/clear ล้าง conversation (เริ่มใหม่)
|
|
29
40
|
/compact, /compress
|
|
@@ -37,14 +48,6 @@ const HELP_TEXT = `คำสั่ง:
|
|
|
37
48
|
custom commands:
|
|
38
49
|
~/.sanook/commands/<name>.md และ .sanook/commands/<name>.md (project ต้อง trust ก่อน)
|
|
39
50
|
args: ใช้ $ARGUMENTS หรือ {{ args }}; ถ้าไม่มี placeholder จะ append args ต่อท้าย`;
|
|
40
|
-
const TOOLS_LIST = [
|
|
41
|
-
'read_file (offset/limit) write_file edit_file (replace_all) list_dir glob grep run_bash',
|
|
42
|
-
'git_status git_diff git_log git_commit',
|
|
43
|
-
'remember recall · skill find_skills create_skill',
|
|
44
|
-
'schedule_task list_scheduled cancel_scheduled',
|
|
45
|
-
'task task_parallel task_spawn task_collect task_cancel task_status ← sub-agent (ขนาน/background)',
|
|
46
|
-
'diagnostics ← type error/lint จาก language server (LSP)',
|
|
47
|
-
].join('\n ');
|
|
48
51
|
const MESSAGING_PLATFORMS = [
|
|
49
52
|
'telegram',
|
|
50
53
|
'discord',
|
|
@@ -167,16 +170,42 @@ export function parseCommand(input, ctx) {
|
|
|
167
170
|
return { handled: true, action: 'clear', message: 'ล้าง conversation แล้ว' };
|
|
168
171
|
case 'status':
|
|
169
172
|
return { handled: true, message: statusMenu(ctx) };
|
|
173
|
+
case 'hotkeys':
|
|
174
|
+
return { handled: true, action: 'hotkeys', message: formatHotkeys() };
|
|
170
175
|
case 'compact':
|
|
171
176
|
case 'compress':
|
|
172
177
|
return { handled: true, action: 'compact', message: 'บีบ context แล้ว' };
|
|
178
|
+
case 'copy': {
|
|
179
|
+
const target = args[0]?.toLowerCase();
|
|
180
|
+
if (!target || target === 'last' || target === 'assistant')
|
|
181
|
+
return { handled: true, action: 'copyLast' };
|
|
182
|
+
return { handled: true, message: 'ใช้ /copy หรือ /copy last' };
|
|
183
|
+
}
|
|
173
184
|
case 'quit':
|
|
174
185
|
case 'exit':
|
|
175
186
|
return { handled: true, action: 'quit' };
|
|
176
187
|
case 'model':
|
|
177
188
|
if (!args[0])
|
|
178
|
-
return { handled: true, message: modelMenu(ctx.model) };
|
|
189
|
+
return { handled: true, action: 'modelPicker', message: modelMenu(ctx.model) };
|
|
179
190
|
return modelChange(args[0]);
|
|
191
|
+
case 'setup':
|
|
192
|
+
return {
|
|
193
|
+
handled: true,
|
|
194
|
+
message: [
|
|
195
|
+
`${BRAND.productName} setup (Hermes-style sections):`,
|
|
196
|
+
` 1. ${BRAND.cliName} setup model — provider + model wizard`,
|
|
197
|
+
` 2. ${BRAND.cliName} setup agent — permissionMode, budget, personality`,
|
|
198
|
+
` 3. ${BRAND.cliName} setup tools — built-in tools + MCP`,
|
|
199
|
+
` 4. ${BRAND.cliName} setup gateway — Telegram/Discord/Slack/…`,
|
|
200
|
+
` 5. ${BRAND.cliName} setup brain — second-brain vault`,
|
|
201
|
+
` หรือรัน ${BRAND.cliName} ครั้งแรก → wizard 10 ขั้น (ภาษา → … → gateway → brain)`,
|
|
202
|
+
].join('\n'),
|
|
203
|
+
};
|
|
204
|
+
case 'dashboard':
|
|
205
|
+
return {
|
|
206
|
+
handled: true,
|
|
207
|
+
message: `Sanook Dashboard — รัน: ${BRAND.cliName} dashboard\n แล้วเปิด http://127.0.0.1:9119 (Chat · Files · Logs · Cron · Channels)`,
|
|
208
|
+
};
|
|
180
209
|
case 'personality': {
|
|
181
210
|
const raw = args.join(' ').trim();
|
|
182
211
|
if (!raw)
|
|
@@ -192,11 +221,55 @@ export function parseCommand(input, ctx) {
|
|
|
192
221
|
};
|
|
193
222
|
}
|
|
194
223
|
case 'tools':
|
|
195
|
-
return { handled: true, message: `tools ที่ agent ใช้ได้ (+ MCP ที่ตั้งไว้):\n ${
|
|
224
|
+
return { handled: true, action: 'toolsHub', message: `tools ที่ agent ใช้ได้ (+ MCP ที่ตั้งไว้):\n ${formatToolCatalog()}` };
|
|
225
|
+
case 'trail': {
|
|
226
|
+
const rawMode = args[0]?.toLowerCase();
|
|
227
|
+
if (!rawMode)
|
|
228
|
+
return { handled: true, action: 'toolTrail', message: 'toggle tool trail view' };
|
|
229
|
+
if (['compact', 'collapse', 'collapsed', 'hide', 'summary'].includes(rawMode)) {
|
|
230
|
+
return { handled: true, action: 'toolTrail', message: 'tool trail → compact', toolTrailMode: 'compact' };
|
|
231
|
+
}
|
|
232
|
+
if (['expanded', 'expand', 'full', 'show'].includes(rawMode)) {
|
|
233
|
+
return { handled: true, action: 'toolTrail', message: 'tool trail → expanded', toolTrailMode: 'expanded' };
|
|
234
|
+
}
|
|
235
|
+
return { handled: true, message: 'ใช้ /trail, /trail compact, หรือ /trail expanded' };
|
|
236
|
+
}
|
|
237
|
+
case 'details': {
|
|
238
|
+
const section = args[0]?.toLowerCase();
|
|
239
|
+
const mode = args[1]?.toLowerCase();
|
|
240
|
+
const usage = 'ใช้ /details thinking|tools hidden|collapsed|expanded';
|
|
241
|
+
if (!section && !mode)
|
|
242
|
+
return { handled: true, message: usage };
|
|
243
|
+
if (section !== 'thinking' && section !== 'tools')
|
|
244
|
+
return { handled: true, message: usage };
|
|
245
|
+
if (mode !== 'hidden' && mode !== 'collapsed' && mode !== 'expanded')
|
|
246
|
+
return { handled: true, message: usage };
|
|
247
|
+
return {
|
|
248
|
+
handled: true,
|
|
249
|
+
action: 'details',
|
|
250
|
+
detailMode: mode,
|
|
251
|
+
detailSection: section,
|
|
252
|
+
message: `details ${section} → ${mode}`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
196
255
|
case 'platforms':
|
|
197
256
|
return { handled: true, message: platformMenu() };
|
|
257
|
+
case 'mcp':
|
|
258
|
+
return {
|
|
259
|
+
handled: true,
|
|
260
|
+
action: 'mcpHub',
|
|
261
|
+
message: `MCP servers — จัดการด้วย "${BRAND.cliName} mcp list/search/install/doctor"`,
|
|
262
|
+
};
|
|
198
263
|
case 'skills':
|
|
199
|
-
return {
|
|
264
|
+
return {
|
|
265
|
+
handled: true,
|
|
266
|
+
action: 'skillsHub',
|
|
267
|
+
message: `skills โหลดจาก built-in + ${appHomePath('skills')} — จัดการด้วย "${BRAND.cliName} skill list/add/remove"`,
|
|
268
|
+
};
|
|
269
|
+
case 'sessions':
|
|
270
|
+
return { handled: true, action: 'sessionsHub', message: `saved sessions — จัดการด้วย "${BRAND.cliName} sessions"` };
|
|
271
|
+
case 'tasks':
|
|
272
|
+
return { handled: true, action: 'tasksHub', message: 'background tasks — จาก task_spawn (Enter ดูรายละเอียด)' };
|
|
200
273
|
case 'diff':
|
|
201
274
|
return { handled: true, action: 'diff' };
|
|
202
275
|
case 'retry':
|
|
@@ -230,15 +303,22 @@ export const BUILTIN_COMMANDS = new Set([
|
|
|
230
303
|
'new',
|
|
231
304
|
'reset',
|
|
232
305
|
'status',
|
|
306
|
+
'hotkeys',
|
|
233
307
|
'compact',
|
|
234
308
|
'compress',
|
|
309
|
+
'copy',
|
|
235
310
|
'quit',
|
|
236
311
|
'exit',
|
|
237
312
|
'model',
|
|
238
313
|
'personality',
|
|
314
|
+
'details',
|
|
239
315
|
'platforms',
|
|
316
|
+
'trail',
|
|
240
317
|
'tools',
|
|
318
|
+
'mcp',
|
|
241
319
|
'skills',
|
|
320
|
+
'sessions',
|
|
321
|
+
'tasks',
|
|
242
322
|
'diff',
|
|
243
323
|
'retry',
|
|
244
324
|
'stop',
|
package/dist/config.js
CHANGED
|
@@ -46,6 +46,8 @@ export const ConfigSchema = z.object({
|
|
|
46
46
|
embeddingModel: z.string().optional().catch(undefined),
|
|
47
47
|
// Hermes-style /personality overlay (stored as a small named prompt)
|
|
48
48
|
personality: z.string().optional().catch(undefined),
|
|
49
|
+
/** UI + setup wizard language */
|
|
50
|
+
locale: z.enum(['en', 'th']).catch('th').default('th'),
|
|
49
51
|
});
|
|
50
52
|
const DEFAULT_THINKING_BUDGET = 4096;
|
|
51
53
|
function normalizeThinkingBudget(value) {
|
|
@@ -99,10 +101,33 @@ export async function agentTuning() {
|
|
|
99
101
|
const summaryModel = trimmedString(process.env.SANOOK_SUMMARY_MODEL) ?? trimmedString(raw.summaryModel);
|
|
100
102
|
return { cacheTtl, thinkingBudget, compaction, contextCompression, summaryModel };
|
|
101
103
|
}
|
|
104
|
+
const warnedBadConfigKeys = new Set();
|
|
105
|
+
/**
|
|
106
|
+
* Validate the merged config, but degrade gracefully: a malformed strict field (bad model/maxSteps/
|
|
107
|
+
* permissionMode/budgetUsd/pricing in a hand-edited config.json) is dropped to its default with a
|
|
108
|
+
* one-time stderr warning instead of throwing and crashing boot. Security-sensitive fields drop to
|
|
109
|
+
* the SAFE default (budgetUsd→no cap is still surfaced by the warning; pricing→none).
|
|
110
|
+
*/
|
|
111
|
+
function parseConfigGraceful(merged) {
|
|
112
|
+
const first = ConfigSchema.safeParse(merged);
|
|
113
|
+
if (first.success)
|
|
114
|
+
return first.data;
|
|
115
|
+
const badKeys = [...new Set(first.error.issues.map((i) => String(i.path[0])).filter(Boolean))];
|
|
116
|
+
const cleaned = { ...merged };
|
|
117
|
+
for (const k of badKeys)
|
|
118
|
+
delete cleaned[k];
|
|
119
|
+
const fresh = badKeys.filter((k) => !warnedBadConfigKeys.has(k));
|
|
120
|
+
if (fresh.length) {
|
|
121
|
+
fresh.forEach((k) => warnedBadConfigKeys.add(k));
|
|
122
|
+
process.stderr.write(`${BRAND.cliName}: ⚠ ละเลย config ที่ค่าผิด (ใช้ค่า default แทน): ${fresh.join(', ')}\n`);
|
|
123
|
+
}
|
|
124
|
+
const second = ConfigSchema.safeParse(cleaned);
|
|
125
|
+
return second.success ? second.data : ConfigSchema.parse({});
|
|
126
|
+
}
|
|
102
127
|
async function readJson(path) {
|
|
103
128
|
try {
|
|
104
129
|
const parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
105
|
-
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
130
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
106
131
|
}
|
|
107
132
|
catch {
|
|
108
133
|
return {}; // ไม่มีไฟล์ / parse ไม่ได้ = ใช้ default
|
|
@@ -134,15 +159,16 @@ export async function loadConfig(overrides = {}, cwd = process.cwd()) {
|
|
|
134
159
|
const trust = await projectTrustStatus(root);
|
|
135
160
|
const project = trust.trusted ? projectRaw : sanitizeUntrustedProjectConfig(projectRaw);
|
|
136
161
|
const envConfig = {};
|
|
137
|
-
|
|
138
|
-
|
|
162
|
+
const envModel = trimmedString(process.env[BRAND.modelEnvVar]);
|
|
163
|
+
if (envModel)
|
|
164
|
+
envConfig.model = envModel;
|
|
139
165
|
const cleanOverrides = {};
|
|
140
166
|
for (const [k, v] of Object.entries(overrides)) {
|
|
141
167
|
if (v !== undefined)
|
|
142
168
|
cleanOverrides[k] = v;
|
|
143
169
|
}
|
|
144
170
|
const merged = { ...global, ...project, ...envConfig, ...cleanOverrides };
|
|
145
|
-
const config =
|
|
171
|
+
const config = parseConfigGraceful(merged);
|
|
146
172
|
// pricing override: config.pricing + env SANOOK_PRICING (JSON) → ลงทะเบียนเข้า cost table
|
|
147
173
|
registerPricing(config.pricing);
|
|
148
174
|
registerPricing(parseEnvPricing());
|
|
@@ -188,7 +214,7 @@ export async function isFirstRun() {
|
|
|
188
214
|
return true;
|
|
189
215
|
}
|
|
190
216
|
}
|
|
191
|
-
/** บันทึก global config (model/provider ที่เลือกตอน setup) */
|
|
217
|
+
/** บันทึก global config (model/provider/locale ที่เลือกตอน setup) */
|
|
192
218
|
export async function saveGlobalConfig(cfg) {
|
|
193
219
|
await mkdir(CONFIG_DIR, { recursive: true });
|
|
194
220
|
const existing = await readJson(CONFIG_PATH);
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { termList } from './search/index-core.js';
|
|
4
|
+
const PACK_DIR = 'Shared/Context-Packs';
|
|
5
|
+
const MIN_SCORE = 0.35;
|
|
6
|
+
const DEFAULT_MAX_CHARS = 1200;
|
|
7
|
+
/** Known packs + retrieval signals (aligned with Shared/Context-Packs/_Index.md). */
|
|
8
|
+
const PACK_CATALOG = [
|
|
9
|
+
{
|
|
10
|
+
slug: 'second-brain-maintenance',
|
|
11
|
+
title: 'Second-Brain Maintenance',
|
|
12
|
+
description: 'vault structure, routing rules, memory policy, indexes, runbooks, agent adapters',
|
|
13
|
+
signalTerms: [
|
|
14
|
+
'vault',
|
|
15
|
+
'structure',
|
|
16
|
+
'routing',
|
|
17
|
+
'memory',
|
|
18
|
+
'policy',
|
|
19
|
+
'index',
|
|
20
|
+
'runbook',
|
|
21
|
+
'agent',
|
|
22
|
+
'adapter',
|
|
23
|
+
'framework',
|
|
24
|
+
'obsidian',
|
|
25
|
+
'maintenance',
|
|
26
|
+
'brain',
|
|
27
|
+
'scaffold',
|
|
28
|
+
'frontmatter',
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
slug: 'coding-release',
|
|
33
|
+
title: 'Coding & Release',
|
|
34
|
+
description: 'source code, tests, build/release, CLI commands, runtime scripts',
|
|
35
|
+
signalTerms: [
|
|
36
|
+
'code',
|
|
37
|
+
'coding',
|
|
38
|
+
'test',
|
|
39
|
+
'tests',
|
|
40
|
+
'build',
|
|
41
|
+
'release',
|
|
42
|
+
'cli',
|
|
43
|
+
'script',
|
|
44
|
+
'implement',
|
|
45
|
+
'fix',
|
|
46
|
+
'bug',
|
|
47
|
+
'typecheck',
|
|
48
|
+
'npm',
|
|
49
|
+
'ship',
|
|
50
|
+
'deploy',
|
|
51
|
+
'refactor',
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
slug: 'research-to-framework',
|
|
56
|
+
title: 'Research To Framework',
|
|
57
|
+
description: 'research, experiment, comparison, promote findings into framework',
|
|
58
|
+
signalTerms: [
|
|
59
|
+
'research',
|
|
60
|
+
'experiment',
|
|
61
|
+
'framework',
|
|
62
|
+
'benchmark',
|
|
63
|
+
'eval',
|
|
64
|
+
'hypothesis',
|
|
65
|
+
'promote',
|
|
66
|
+
'distillation',
|
|
67
|
+
'comparison',
|
|
68
|
+
'method',
|
|
69
|
+
'sota',
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
function catalogEntry(slug) {
|
|
74
|
+
const base = PACK_CATALOG.find((item) => item.slug === slug);
|
|
75
|
+
if (!base)
|
|
76
|
+
throw new Error(`unknown context pack slug: ${slug}`);
|
|
77
|
+
return { ...base, relPath: `${PACK_DIR}/${slug}.md` };
|
|
78
|
+
}
|
|
79
|
+
function packTerms(pack) {
|
|
80
|
+
return new Set([...termList(pack.slug), ...termList(pack.title), ...pack.signalTerms.map((t) => t.toLowerCase())]);
|
|
81
|
+
}
|
|
82
|
+
/** Score query against a pack via token overlap (deterministic, no network). */
|
|
83
|
+
export function scoreContextPack(query, pack) {
|
|
84
|
+
const queryTerms = termList(query);
|
|
85
|
+
if (!queryTerms.length)
|
|
86
|
+
return { score: 0, matchedTerms: [] };
|
|
87
|
+
const signals = packTerms(pack);
|
|
88
|
+
const matchedTerms = queryTerms.filter((term) => signals.has(term));
|
|
89
|
+
if (!matchedTerms.length)
|
|
90
|
+
return { score: 0, matchedTerms: [] };
|
|
91
|
+
const recall = matchedTerms.length / queryTerms.length;
|
|
92
|
+
const precision = matchedTerms.length / signals.size;
|
|
93
|
+
return { score: recall * 0.7 + precision * 0.3, matchedTerms };
|
|
94
|
+
}
|
|
95
|
+
export async function listContextPacks(brainPath) {
|
|
96
|
+
const dir = join(brainPath, PACK_DIR);
|
|
97
|
+
let entries;
|
|
98
|
+
try {
|
|
99
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
const slugs = new Set(entries.filter((e) => e.isFile() && e.name.endsWith('.md') && e.name !== '_Index.md').map((e) => e.name.replace(/\.md$/i, '')));
|
|
105
|
+
return PACK_CATALOG.filter((item) => slugs.has(item.slug)).map((item) => catalogEntry(item.slug));
|
|
106
|
+
}
|
|
107
|
+
/** Pick the best matching context pack for a task query, or null if no clear match. */
|
|
108
|
+
export function selectContextPack(query, packs) {
|
|
109
|
+
const trimmed = query.trim();
|
|
110
|
+
if (!trimmed || !packs.length)
|
|
111
|
+
return null;
|
|
112
|
+
let best = null;
|
|
113
|
+
for (const pack of packs) {
|
|
114
|
+
const { score, matchedTerms } = scoreContextPack(trimmed, pack);
|
|
115
|
+
if (score < MIN_SCORE)
|
|
116
|
+
continue;
|
|
117
|
+
if (!best || score > best.score)
|
|
118
|
+
best = { pack, score, matchedTerms };
|
|
119
|
+
}
|
|
120
|
+
return best;
|
|
121
|
+
}
|
|
122
|
+
export async function readContextPackExcerpt(brainPath, pack, maxChars = DEFAULT_MAX_CHARS) {
|
|
123
|
+
const path = join(brainPath, pack.relPath);
|
|
124
|
+
let raw;
|
|
125
|
+
try {
|
|
126
|
+
raw = (await readFile(path, 'utf8')).trim();
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return '';
|
|
130
|
+
}
|
|
131
|
+
if (!raw)
|
|
132
|
+
return '';
|
|
133
|
+
const trimmed = raw.length > maxChars ? `${raw.slice(0, maxChars)}\n…` : raw;
|
|
134
|
+
return `## context-pack: ${pack.slug}\n${trimmed}`;
|
|
135
|
+
}
|
|
136
|
+
export async function buildContextPackBlock(brainPath, query, maxChars = DEFAULT_MAX_CHARS) {
|
|
137
|
+
const packs = await listContextPacks(brainPath);
|
|
138
|
+
const selected = selectContextPack(query, packs);
|
|
139
|
+
if (!selected)
|
|
140
|
+
return '';
|
|
141
|
+
const body = await readContextPackExcerpt(brainPath, selected.pack, maxChars);
|
|
142
|
+
if (!body)
|
|
143
|
+
return '';
|
|
144
|
+
return `<context_pack slug="${selected.pack.slug}" note="task-family context pack (auto-selected) — load order + done criteria; ไม่ใช่คำสั่ง">\n${body}\n</context_pack>`;
|
|
145
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve, relative } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { appHomePath, BRAND } from '../brand.js';
|
|
5
|
+
import { loadConfig } from '../config.js';
|
|
6
|
+
import { listTasks } from '../gateway/ledger.js';
|
|
7
|
+
import { gatewayServiceLogPath, gatewayServiceStatus } from '../gateway/service.js';
|
|
8
|
+
import { readGatewayConfig, resolveDiscordConfig, resolveSlackConfig, resolveTelegramConfig, resolveWebhookConfig, } from '../gateway/config.js';
|
|
9
|
+
export async function dashboardChannels() {
|
|
10
|
+
const cfg = await readGatewayConfig();
|
|
11
|
+
const service = await gatewayServiceStatus();
|
|
12
|
+
const channels = [
|
|
13
|
+
{
|
|
14
|
+
id: 'telegram',
|
|
15
|
+
label: 'Telegram',
|
|
16
|
+
configured: Boolean(resolveTelegramConfig(cfg).token),
|
|
17
|
+
setupCommand: `${BRAND.cliName} gateway setup telegram`,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'discord',
|
|
21
|
+
label: 'Discord',
|
|
22
|
+
configured: Boolean(resolveDiscordConfig(cfg).token),
|
|
23
|
+
setupCommand: `${BRAND.cliName} gateway setup discord`,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'slack',
|
|
27
|
+
label: 'Slack',
|
|
28
|
+
configured: Boolean(resolveSlackConfig(cfg).botToken),
|
|
29
|
+
setupCommand: `${BRAND.cliName} gateway setup slack`,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'webhooks',
|
|
33
|
+
label: 'Webhooks',
|
|
34
|
+
configured: Object.keys(resolveWebhookConfig(cfg).routes ?? {}).length > 0,
|
|
35
|
+
setupCommand: `${BRAND.cliName} webhook setup`,
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
return { channels, serviceRunning: service.running };
|
|
39
|
+
}
|
|
40
|
+
export async function dashboardCronTasks() {
|
|
41
|
+
return { tasks: await listTasks() };
|
|
42
|
+
}
|
|
43
|
+
export async function dashboardLogsTail(maxLines = 200) {
|
|
44
|
+
const path = gatewayServiceLogPath();
|
|
45
|
+
try {
|
|
46
|
+
const raw = await readFile(path, 'utf8');
|
|
47
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
48
|
+
return { path, lines: lines.slice(-maxLines) };
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return { path, lines: [`(no log yet — run ${BRAND.cliName} serve)`] };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function safeRoot(root) {
|
|
55
|
+
return resolve(root);
|
|
56
|
+
}
|
|
57
|
+
export async function dashboardListFiles(subpath = '') {
|
|
58
|
+
const config = await loadConfig({});
|
|
59
|
+
const roots = [appHomePath(), config.brainPath ? resolve(config.brainPath) : null].filter(Boolean);
|
|
60
|
+
const root = safeRoot(roots[0] ?? appHomePath());
|
|
61
|
+
const target = safeRoot(join(root, subpath.replace(/^\/+/, '')));
|
|
62
|
+
if (!target.startsWith(root) && !roots.some((r) => target.startsWith(safeRoot(r)))) {
|
|
63
|
+
throw new Error('path not allowed');
|
|
64
|
+
}
|
|
65
|
+
const entries = await readdir(target, { withFileTypes: true });
|
|
66
|
+
return {
|
|
67
|
+
root,
|
|
68
|
+
entries: entries
|
|
69
|
+
.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
|
|
70
|
+
.slice(0, 200)
|
|
71
|
+
.map((e) => ({ name: e.name, dir: e.isDirectory() })),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export async function dashboardReadFile(subpath) {
|
|
75
|
+
const config = await loadConfig({});
|
|
76
|
+
const allowedRoots = [appHomePath(), config.brainPath ? resolve(config.brainPath) : null].filter(Boolean);
|
|
77
|
+
const target = safeRoot(subpath.startsWith('/') ? subpath : join(appHomePath(), subpath));
|
|
78
|
+
if (!allowedRoots.some((root) => target.startsWith(safeRoot(root))))
|
|
79
|
+
throw new Error('path not allowed');
|
|
80
|
+
const info = await stat(target);
|
|
81
|
+
if (!info.isFile())
|
|
82
|
+
throw new Error('not a file');
|
|
83
|
+
if (info.size > 512_000)
|
|
84
|
+
throw new Error('file too large');
|
|
85
|
+
const content = await readFile(target, 'utf8');
|
|
86
|
+
return { path: relative(homedir(), target) || target, content };
|
|
87
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { join, extname } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { BRAND } from '../brand.js';
|
|
6
|
+
import { loadConfig, readGlobalConfigRaw } from '../config.js';
|
|
7
|
+
import { listSessions } from '../session.js';
|
|
8
|
+
import { loadMcpConfig } from '../mcp.js';
|
|
9
|
+
const MIME = {
|
|
10
|
+
'.html': 'text/html; charset=utf-8',
|
|
11
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
12
|
+
'.css': 'text/css; charset=utf-8',
|
|
13
|
+
'.json': 'application/json; charset=utf-8',
|
|
14
|
+
'.svg': 'image/svg+xml',
|
|
15
|
+
'.png': 'image/png',
|
|
16
|
+
'.ico': 'image/x-icon',
|
|
17
|
+
};
|
|
18
|
+
function dashboardStaticDir() {
|
|
19
|
+
const here = fileURLToPath(new URL('.', import.meta.url));
|
|
20
|
+
return join(here, 'static');
|
|
21
|
+
}
|
|
22
|
+
async function readBody(req) {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
for await (const chunk of req)
|
|
25
|
+
chunks.push(Buffer.from(chunk));
|
|
26
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
27
|
+
}
|
|
28
|
+
function json(res, status, body) {
|
|
29
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
30
|
+
res.end(`${JSON.stringify(body)}\n`);
|
|
31
|
+
}
|
|
32
|
+
async function handleApi(req, res, pathname) {
|
|
33
|
+
if (req.method === 'GET' && pathname === '/api/status') {
|
|
34
|
+
const config = await loadConfig({});
|
|
35
|
+
const raw = await readGlobalConfigRaw();
|
|
36
|
+
json(res, 200, {
|
|
37
|
+
product: 'Sanook Dashboard',
|
|
38
|
+
cli: BRAND.cliName,
|
|
39
|
+
version: process.env.npm_package_version ?? 'dev',
|
|
40
|
+
model: config.model,
|
|
41
|
+
locale: config.locale,
|
|
42
|
+
brainPath: config.brainPath ?? null,
|
|
43
|
+
permissionMode: config.permissionMode,
|
|
44
|
+
gatewayHint: `${BRAND.cliName} serve`,
|
|
45
|
+
});
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (req.method === 'GET' && pathname === '/api/config') {
|
|
49
|
+
json(res, 200, await readGlobalConfigRaw());
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
if (req.method === 'GET' && pathname === '/api/sessions') {
|
|
53
|
+
const sessions = await listSessions({});
|
|
54
|
+
json(res, 200, { sessions });
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
if (req.method === 'GET' && pathname === '/api/mcp') {
|
|
58
|
+
const servers = await loadMcpConfig();
|
|
59
|
+
json(res, 200, { servers });
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
if (req.method === 'GET' && pathname === '/api/brain') {
|
|
63
|
+
const config = await loadConfig({});
|
|
64
|
+
json(res, 200, { brainPath: config.brainPath ?? null });
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
if (req.method === 'GET' && pathname === '/api/cron') {
|
|
68
|
+
const { dashboardCronTasks } = await import('./api-helpers.js');
|
|
69
|
+
json(res, 200, await dashboardCronTasks());
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
if (req.method === 'GET' && pathname === '/api/channels') {
|
|
73
|
+
const { dashboardChannels } = await import('./api-helpers.js');
|
|
74
|
+
json(res, 200, await dashboardChannels());
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if (req.method === 'GET' && pathname === '/api/logs') {
|
|
78
|
+
const { dashboardLogsTail } = await import('./api-helpers.js');
|
|
79
|
+
json(res, 200, await dashboardLogsTail());
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
if (req.method === 'GET' && pathname.startsWith('/api/files')) {
|
|
83
|
+
const url = new URL(req.url ?? '/', 'http://local');
|
|
84
|
+
const sub = url.searchParams.get('path') ?? '';
|
|
85
|
+
if (pathname === '/api/files/read') {
|
|
86
|
+
const { dashboardReadFile } = await import('./api-helpers.js');
|
|
87
|
+
json(res, 200, await dashboardReadFile(sub));
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
const { dashboardListFiles } = await import('./api-helpers.js');
|
|
91
|
+
json(res, 200, await dashboardListFiles(sub));
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
if (req.method === 'GET' && pathname === '/api/chat/status') {
|
|
95
|
+
json(res, 200, {
|
|
96
|
+
hint: `Use ${BRAND.cliName} in terminal, or start ${BRAND.cliName} serve for HTTP chat`,
|
|
97
|
+
gateway: `${BRAND.cliName} serve`,
|
|
98
|
+
});
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
if (req.method === 'POST' && pathname === '/api/config') {
|
|
102
|
+
const raw = await readBody(req);
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(raw || '{}');
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
json(res, 400, { error: 'invalid JSON' });
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
112
|
+
json(res, 400, { error: 'body must be an object' });
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
const { saveGlobalConfig } = await import('../config.js');
|
|
116
|
+
await saveGlobalConfig(parsed);
|
|
117
|
+
json(res, 200, { ok: true });
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
async function serveStatic(res, staticDir, pathname) {
|
|
123
|
+
const safe = pathname === '/' ? '/index.html' : pathname;
|
|
124
|
+
const filePath = join(staticDir, safe.replace(/^\/+/, ''));
|
|
125
|
+
try {
|
|
126
|
+
const info = await stat(filePath);
|
|
127
|
+
if (!info.isFile()) {
|
|
128
|
+
res.writeHead(404);
|
|
129
|
+
res.end('Not found');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const ext = extname(filePath);
|
|
133
|
+
const body = await readFile(filePath);
|
|
134
|
+
res.writeHead(200, { 'Content-Type': MIME[ext] ?? 'application/octet-stream' });
|
|
135
|
+
res.end(body);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
try {
|
|
139
|
+
const fallback = await readFile(join(staticDir, 'index.html'));
|
|
140
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
141
|
+
res.end(fallback);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
res.writeHead(503);
|
|
145
|
+
res.end('Sanook Dashboard assets missing — run npm run build:dashboard');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export async function startDashboardServer(opts = {}) {
|
|
150
|
+
const port = opts.port ?? 9119;
|
|
151
|
+
const host = opts.host ?? '127.0.0.1';
|
|
152
|
+
const staticDir = opts.staticDir ?? dashboardStaticDir();
|
|
153
|
+
const log = opts.onLog ?? (() => { });
|
|
154
|
+
const server = createServer(async (req, res) => {
|
|
155
|
+
try {
|
|
156
|
+
const url = new URL(req.url ?? '/', `http://${host}`);
|
|
157
|
+
if (url.pathname.startsWith('/api/')) {
|
|
158
|
+
const handled = await handleApi(req, res, url.pathname);
|
|
159
|
+
if (handled)
|
|
160
|
+
return;
|
|
161
|
+
json(res, 404, { error: 'not found' });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
await serveStatic(res, staticDir, url.pathname);
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
json(res, 500, { error: e.message });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
await new Promise((resolve, reject) => {
|
|
171
|
+
server.once('error', reject);
|
|
172
|
+
server.listen(port, host, () => resolve());
|
|
173
|
+
});
|
|
174
|
+
log(`Sanook Dashboard — http://${host}:${port}`);
|
|
175
|
+
return () => server.close();
|
|
176
|
+
}
|
|
177
|
+
export function dashboardStaticRoot() {
|
|
178
|
+
return dashboardStaticDir();
|
|
179
|
+
}
|