sanook-cli 0.5.2 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +91 -2
  2. package/README.md +15 -3
  3. package/README.th.md +8 -1
  4. package/dist/approval.js +7 -0
  5. package/dist/bin.js +623 -56
  6. package/dist/brain-consolidate.js +335 -0
  7. package/dist/brain-context.js +42 -3
  8. package/dist/brain-final.js +15 -9
  9. package/dist/brain-metrics.js +277 -0
  10. package/dist/brain-new.js +402 -0
  11. package/dist/brain-pack.js +210 -0
  12. package/dist/brain-repair.js +280 -0
  13. package/dist/brain.js +3 -0
  14. package/dist/cli-args.js +47 -9
  15. package/dist/cli-option-values.js +1 -1
  16. package/dist/clipboard.js +65 -0
  17. package/dist/commands.js +94 -14
  18. package/dist/config.js +31 -5
  19. package/dist/context-pack.js +145 -0
  20. package/dist/dashboard/api-helpers.js +87 -0
  21. package/dist/dashboard/server.js +179 -0
  22. package/dist/dashboard/static/app.js +277 -0
  23. package/dist/dashboard/static/index.html +39 -0
  24. package/dist/dashboard/static/styles.css +85 -0
  25. package/dist/diff.js +10 -2
  26. package/dist/gateway/auth.js +14 -3
  27. package/dist/gateway/deliver.js +45 -3
  28. package/dist/gateway/doctor.js +456 -0
  29. package/dist/gateway/email.js +30 -1
  30. package/dist/gateway/ledger.js +20 -1
  31. package/dist/gateway/session.js +30 -11
  32. package/dist/hotkeys.js +21 -0
  33. package/dist/i18n/en.js +98 -0
  34. package/dist/i18n/index.js +19 -0
  35. package/dist/i18n/th.js +98 -0
  36. package/dist/i18n/types.js +1 -0
  37. package/dist/insights-args.js +24 -4
  38. package/dist/knowledge.js +55 -29
  39. package/dist/loop.js +34 -5
  40. package/dist/mcp-hub.js +33 -0
  41. package/dist/mcp-registry.js +153 -9
  42. package/dist/mcp-risk.js +71 -0
  43. package/dist/mcp.js +77 -5
  44. package/dist/memory-log.js +90 -0
  45. package/dist/memory-store.js +37 -1
  46. package/dist/memory.js +51 -7
  47. package/dist/model-picker.js +58 -0
  48. package/dist/orchestrate.js +7 -5
  49. package/dist/plan-handoff.js +17 -0
  50. package/dist/polyglot.js +162 -0
  51. package/dist/process-runner.js +96 -0
  52. package/dist/project-init.js +91 -0
  53. package/dist/project-registry.js +143 -0
  54. package/dist/project-scaffold.js +124 -0
  55. package/dist/prompt-size.js +155 -0
  56. package/dist/providers/codex-login.js +138 -0
  57. package/dist/providers/codex.js +20 -8
  58. package/dist/providers/keys.js +21 -0
  59. package/dist/providers/models.js +1 -1
  60. package/dist/search/cli.js +9 -1
  61. package/dist/search/embedding-config.js +22 -0
  62. package/dist/search/engine.js +2 -13
  63. package/dist/search/indexer.js +10 -10
  64. package/dist/session-distill.js +84 -0
  65. package/dist/session.js +1 -11
  66. package/dist/skill-install.js +24 -1
  67. package/dist/skills.js +33 -0
  68. package/dist/slash-completion.js +155 -0
  69. package/dist/support-dump.js +31 -0
  70. package/dist/tool-catalog.js +59 -0
  71. package/dist/tools/index.js +5 -0
  72. package/dist/tools/permission.js +82 -16
  73. package/dist/tools/polyglot.js +126 -0
  74. package/dist/tools/sandbox.js +38 -13
  75. package/dist/tools/search.js +9 -2
  76. package/dist/tools/task.js +22 -2
  77. package/dist/tools/timeout.js +7 -5
  78. package/dist/tools/web-fetch-tool.js +33 -0
  79. package/dist/turn-retrieval.js +83 -0
  80. package/dist/ui/app.js +835 -29
  81. package/dist/ui/banner.js +78 -4
  82. package/dist/ui/markdown.js +122 -0
  83. package/dist/ui/overlay.js +496 -0
  84. package/dist/ui/queue.js +23 -0
  85. package/dist/ui/render.js +20 -1
  86. package/dist/ui/session-panel.js +115 -0
  87. package/dist/ui/setup-providers.js +40 -0
  88. package/dist/ui/setup.js +163 -50
  89. package/dist/ui/status.js +142 -0
  90. package/dist/ui/thinking-panel.js +36 -0
  91. package/dist/ui/tool-trail.js +97 -0
  92. package/dist/ui/transcript.js +26 -0
  93. package/dist/ui/useBusyElapsed.js +19 -0
  94. package/dist/ui/useEditor.js +144 -5
  95. package/dist/ui/useGitBranch.js +57 -0
  96. package/dist/update.js +32 -6
  97. package/dist/web-fetch.js +637 -0
  98. package/dist/web-surface.js +190 -0
  99. package/package.json +2 -2
  100. package/second-brain/Projects/_Index.md +17 -4
  101. package/second-brain/Projects/sanook-cli/_Index.md +7 -3
  102. package/second-brain/Projects/sanook-cli/context.md +35 -0
  103. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  104. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  105. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  106. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +52 -11
  107. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  108. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  109. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  110. package/second-brain/Research/_Index.md +2 -0
  111. package/second-brain/Shared/Operating-State/current-state.md +14 -23
  112. package/second-brain/Shared/Tech-Standards/_Index.md +2 -0
  113. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  114. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  115. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  116. package/second-brain/Templates/project-workspace/context.md +28 -0
  117. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  118. package/second-brain/Templates/project-workspace/overview.md +39 -0
  119. package/second-brain/Templates/project-workspace/repo.md +33 -0
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
- const HELP_TEXT = `คำสั่ง:
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 (เช่น /model opus, /model openai:gpt-5)
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
- /skills ดูจำนวน skills (จัดการ: ${BRAND.cliName} skill list)
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 ${TOOLS_LIST}` };
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 { handled: true, message: `skills โหลดจาก built-in + ${appHomePath('skills')} — จัดการด้วย "${BRAND.cliName} skill list/add/remove"` };
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
- if (process.env[BRAND.modelEnvVar])
138
- envConfig.model = process.env[BRAND.modelEnvVar];
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 = ConfigSchema.parse(merged);
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
+ }