sanook-cli 0.5.2 → 0.5.7

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 (127) hide show
  1. package/CHANGELOG.md +112 -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 +637 -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-link.js +73 -0
  10. package/dist/brain-metrics.js +277 -0
  11. package/dist/brain-new.js +402 -0
  12. package/dist/brain-pack.js +210 -0
  13. package/dist/brain-repair.js +280 -0
  14. package/dist/brain.js +3 -0
  15. package/dist/brand.js +4 -0
  16. package/dist/cli-args.js +47 -9
  17. package/dist/cli-option-values.js +1 -1
  18. package/dist/clipboard.js +65 -0
  19. package/dist/commands.js +98 -15
  20. package/dist/config.js +66 -34
  21. package/dist/context-pack.js +145 -0
  22. package/dist/cost.js +20 -0
  23. package/dist/dashboard/api-helpers.js +87 -0
  24. package/dist/dashboard/server.js +179 -0
  25. package/dist/dashboard/static/app.js +277 -0
  26. package/dist/dashboard/static/index.html +39 -0
  27. package/dist/dashboard/static/styles.css +85 -0
  28. package/dist/diff.js +10 -2
  29. package/dist/gateway/auth.js +14 -3
  30. package/dist/gateway/deliver.js +45 -3
  31. package/dist/gateway/doctor.js +456 -0
  32. package/dist/gateway/email.js +30 -1
  33. package/dist/gateway/ledger.js +20 -1
  34. package/dist/gateway/session.js +34 -11
  35. package/dist/hotkeys.js +21 -0
  36. package/dist/i18n/en.js +98 -0
  37. package/dist/i18n/index.js +19 -0
  38. package/dist/i18n/th.js +98 -0
  39. package/dist/i18n/types.js +1 -0
  40. package/dist/insights-args.js +24 -4
  41. package/dist/knowledge.js +55 -29
  42. package/dist/loop.js +65 -9
  43. package/dist/mcp-hub.js +33 -0
  44. package/dist/mcp-registry.js +153 -9
  45. package/dist/mcp-risk.js +71 -0
  46. package/dist/mcp.js +77 -5
  47. package/dist/memory-log.js +90 -0
  48. package/dist/memory-store.js +37 -1
  49. package/dist/memory.js +51 -7
  50. package/dist/model-picker.js +58 -0
  51. package/dist/orchestrate.js +7 -5
  52. package/dist/plan-handoff.js +17 -0
  53. package/dist/polyglot.js +162 -0
  54. package/dist/process-runner.js +96 -0
  55. package/dist/project-init.js +91 -0
  56. package/dist/project-registry.js +143 -0
  57. package/dist/project-scaffold.js +124 -0
  58. package/dist/prompt-size.js +155 -0
  59. package/dist/providers/codex-login.js +138 -0
  60. package/dist/providers/codex.js +20 -8
  61. package/dist/providers/keys.js +21 -0
  62. package/dist/providers/models.js +1 -1
  63. package/dist/providers/registry.js +11 -1
  64. package/dist/search/cli.js +9 -1
  65. package/dist/search/embedding-config.js +22 -0
  66. package/dist/search/engine.js +2 -13
  67. package/dist/search/indexer.js +10 -10
  68. package/dist/session-brain.js +103 -0
  69. package/dist/session-distill.js +84 -0
  70. package/dist/session.js +1 -11
  71. package/dist/skill-install.js +24 -1
  72. package/dist/skills.js +33 -0
  73. package/dist/slash-completion.js +155 -0
  74. package/dist/support-dump.js +31 -0
  75. package/dist/tool-catalog.js +59 -0
  76. package/dist/tools/index.js +5 -0
  77. package/dist/tools/permission.js +82 -16
  78. package/dist/tools/polyglot.js +126 -0
  79. package/dist/tools/sandbox.js +38 -13
  80. package/dist/tools/search.js +9 -2
  81. package/dist/tools/task.js +22 -2
  82. package/dist/tools/timeout.js +7 -5
  83. package/dist/tools/web-fetch-tool.js +33 -0
  84. package/dist/turn-retrieval.js +83 -0
  85. package/dist/ui/app.js +874 -35
  86. package/dist/ui/banner.js +78 -4
  87. package/dist/ui/markdown.js +122 -0
  88. package/dist/ui/overlay.js +496 -0
  89. package/dist/ui/queue.js +23 -0
  90. package/dist/ui/render.js +30 -2
  91. package/dist/ui/session-panel.js +115 -0
  92. package/dist/ui/setup-providers.js +40 -0
  93. package/dist/ui/setup.js +163 -50
  94. package/dist/ui/status.js +142 -0
  95. package/dist/ui/thinking-panel.js +36 -0
  96. package/dist/ui/tool-trail.js +97 -0
  97. package/dist/ui/transcript.js +26 -0
  98. package/dist/ui/useBusyElapsed.js +19 -0
  99. package/dist/ui/useEditor.js +144 -5
  100. package/dist/ui/useGitBranch.js +57 -0
  101. package/dist/update.js +32 -6
  102. package/dist/usage-cli.js +160 -0
  103. package/dist/usage-ledger.js +169 -0
  104. package/dist/web-fetch.js +637 -0
  105. package/dist/web-surface.js +190 -0
  106. package/package.json +4 -3
  107. package/scripts/postinstall.mjs +4 -4
  108. package/second-brain/Projects/_Index.md +17 -4
  109. package/second-brain/Projects/sanook-cli/_Index.md +7 -3
  110. package/second-brain/Projects/sanook-cli/context.md +35 -0
  111. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  112. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  113. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  114. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +52 -11
  115. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  116. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  117. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  118. package/second-brain/Research/_Index.md +2 -0
  119. package/second-brain/Shared/Operating-State/current-state.md +14 -23
  120. package/second-brain/Shared/Tech-Standards/_Index.md +2 -0
  121. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  122. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  123. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  124. package/second-brain/Templates/project-workspace/context.md +28 -0
  125. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  126. package/second-brain/Templates/project-workspace/overview.md +39 -0
  127. package/second-brain/Templates/project-workspace/repo.md +33 -0
@@ -0,0 +1,65 @@
1
+ import { spawn } from 'node:child_process';
2
+ const OSC52_MAX_CHARS = 100_000;
3
+ function powershellSetClipboardScript(text) {
4
+ const b64 = Buffer.from(text, 'utf8').toString('base64');
5
+ return `Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${b64}')))`;
6
+ }
7
+ function clipboardWriteCommands(platform, env) {
8
+ if (platform === 'darwin')
9
+ return [{ args: [], command: 'pbcopy', stdin: true }];
10
+ if (platform === 'win32')
11
+ return [{ args: ['-NoProfile', '-NonInteractive'], command: 'powershell', stdin: false }];
12
+ const commands = [];
13
+ if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) {
14
+ commands.push({ args: ['-NoProfile', '-NonInteractive'], command: 'powershell.exe', stdin: false });
15
+ }
16
+ if (env.WAYLAND_DISPLAY)
17
+ commands.push({ args: ['--type', 'text/plain'], command: 'wl-copy', stdin: true });
18
+ commands.push({ args: ['-selection', 'clipboard', '-in'], command: 'xclip', stdin: true });
19
+ commands.push({ args: ['--clipboard', '--input'], command: 'xsel', stdin: true });
20
+ return commands;
21
+ }
22
+ function runClipboardCommand(command, text, start) {
23
+ return new Promise((resolve) => {
24
+ const args = command.stdin ? command.args : [...command.args, '-Command', powershellSetClipboardScript(text)];
25
+ let child;
26
+ try {
27
+ child = start(command.command, args, { stdio: command.stdin ? ['pipe', 'ignore', 'ignore'] : ['ignore', 'ignore', 'ignore'], windowsHide: true });
28
+ }
29
+ catch {
30
+ resolve(false);
31
+ return;
32
+ }
33
+ child.once('error', () => resolve(false));
34
+ child.once('close', (code) => resolve(code === 0));
35
+ if (command.stdin)
36
+ child.stdin?.end(text);
37
+ });
38
+ }
39
+ export async function writeSystemClipboard(text, options = {}) {
40
+ const env = options.env ?? process.env;
41
+ const platform = options.platform ?? process.platform;
42
+ const start = options.spawn ?? spawn;
43
+ for (const command of clipboardWriteCommands(platform, env)) {
44
+ if (await runClipboardCommand(command, text, start))
45
+ return command.command;
46
+ }
47
+ return null;
48
+ }
49
+ export function osc52Sequence(text) {
50
+ const safe = text.length > OSC52_MAX_CHARS ? text.slice(0, OSC52_MAX_CHARS) : text;
51
+ return `\u001b]52;c;${Buffer.from(safe, 'utf8').toString('base64')}\u0007`;
52
+ }
53
+ export async function copyTextToClipboard(text, options = {}) {
54
+ const payload = text.trimEnd();
55
+ if (!payload.trim())
56
+ throw new Error('ไม่มีข้อความให้ copy');
57
+ const backend = await writeSystemClipboard(payload, options);
58
+ if (backend)
59
+ return { detail: backend, method: 'system' };
60
+ if (options.writeOsc52) {
61
+ options.writeOsc52(osc52Sequence(payload));
62
+ return { detail: 'OSC52', method: 'osc52' };
63
+ }
64
+ throw new Error('ไม่พบ clipboard backend และไม่มี OSC52 output');
65
+ }
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':
@@ -209,7 +282,10 @@ export function parseCommand(input, ctx) {
209
282
  return { handled: true, action: 'rewind' };
210
283
  case 'cost':
211
284
  case 'usage':
212
- return { handled: true, message: ctx.costSummary ?? '(ยังไม่มี usage รอบนี้)' };
285
+ return {
286
+ handled: true,
287
+ message: `${ctx.costSummary ?? '(ยังไม่มี usage รอบนี้)'}\n→ ${BRAND.cliName} usage daily`,
288
+ };
213
289
  case 'insights': {
214
290
  const parsed = parseInsightsArgs(args);
215
291
  if (parsed === null)
@@ -230,15 +306,22 @@ export const BUILTIN_COMMANDS = new Set([
230
306
  'new',
231
307
  'reset',
232
308
  'status',
309
+ 'hotkeys',
233
310
  'compact',
234
311
  'compress',
312
+ 'copy',
235
313
  'quit',
236
314
  'exit',
237
315
  'model',
238
316
  'personality',
317
+ 'details',
239
318
  'platforms',
319
+ 'trail',
240
320
  'tools',
321
+ 'mcp',
241
322
  'skills',
323
+ 'sessions',
324
+ 'tasks',
242
325
  'diff',
243
326
  'retry',
244
327
  'stop',
package/dist/config.js CHANGED
@@ -4,9 +4,12 @@ import { join } from 'node:path';
4
4
  import { appHomePath, appProjectPath, BRAND } from './brand.js';
5
5
  import { projectRoot, projectTrustStatus } from './trust.js';
6
6
  import { registerPricing } from './cost.js';
7
- export const CONFIG_DIR = appHomePath();
8
- const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
9
- const AUTH_PATH = join(CONFIG_DIR, 'auth.json'); // API keys (chmod 0600)
7
+ export function configHomeDir() {
8
+ return appHomePath();
9
+ }
10
+ function authPath() {
11
+ return join(configHomeDir(), 'auth.json');
12
+ }
10
13
  const AUTH_ENV_VAR_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
11
14
  const RESERVED_AUTH_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
12
15
  const PricingKeySchema = z.string().regex(/^[^:\s]+:\S+$/, 'key ต้องเป็น provider:model');
@@ -46,6 +49,8 @@ export const ConfigSchema = z.object({
46
49
  embeddingModel: z.string().optional().catch(undefined),
47
50
  // Hermes-style /personality overlay (stored as a small named prompt)
48
51
  personality: z.string().optional().catch(undefined),
52
+ /** UI + setup wizard language */
53
+ locale: z.enum(['en', 'th']).catch('th').default('th'),
49
54
  });
50
55
  const DEFAULT_THINKING_BUDGET = 4096;
51
56
  function normalizeThinkingBudget(value) {
@@ -99,10 +104,36 @@ export async function agentTuning() {
99
104
  const summaryModel = trimmedString(process.env.SANOOK_SUMMARY_MODEL) ?? trimmedString(raw.summaryModel);
100
105
  return { cacheTtl, thinkingBudget, compaction, contextCompression, summaryModel };
101
106
  }
107
+ const warnedBadConfigKeys = new Set();
108
+ function globalConfigPath() {
109
+ return join(configHomeDir(), 'config.json');
110
+ }
111
+ /**
112
+ * Validate the merged config, but degrade gracefully: a malformed strict field (bad model/maxSteps/
113
+ * permissionMode/budgetUsd/pricing in a hand-edited config.json) is dropped to its default with a
114
+ * one-time stderr warning instead of throwing and crashing boot. Security-sensitive fields drop to
115
+ * the SAFE default (budgetUsd→no cap is still surfaced by the warning; pricing→none).
116
+ */
117
+ function parseConfigGraceful(merged) {
118
+ const first = ConfigSchema.safeParse(merged);
119
+ if (first.success)
120
+ return first.data;
121
+ const badKeys = [...new Set(first.error.issues.map((i) => String(i.path[0])).filter(Boolean))];
122
+ const cleaned = { ...merged };
123
+ for (const k of badKeys)
124
+ delete cleaned[k];
125
+ const fresh = badKeys.filter((k) => !warnedBadConfigKeys.has(k));
126
+ if (fresh.length) {
127
+ fresh.forEach((k) => warnedBadConfigKeys.add(k));
128
+ process.stderr.write(`${BRAND.cliName}: ⚠ ละเลย config ที่ค่าผิด (ใช้ค่า default แทน): ${fresh.join(', ')}\n`);
129
+ }
130
+ const second = ConfigSchema.safeParse(cleaned);
131
+ return second.success ? second.data : ConfigSchema.parse({});
132
+ }
102
133
  async function readJson(path) {
103
134
  try {
104
135
  const parsed = JSON.parse(await readFile(path, 'utf8'));
105
- return parsed && typeof parsed === 'object' ? parsed : {};
136
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
106
137
  }
107
138
  catch {
108
139
  return {}; // ไม่มีไฟล์ / parse ไม่ได้ = ใช้ default
@@ -128,21 +159,22 @@ function sanitizeUntrustedProjectConfig(cfg) {
128
159
  * (config flat — shallow merge พอ; strip undefined ใน overrides กัน override ทับ default)
129
160
  */
130
161
  export async function loadConfig(overrides = {}, cwd = process.cwd()) {
131
- const global = await readJson(CONFIG_PATH);
162
+ const global = await readJson(globalConfigPath());
132
163
  const root = await projectRoot(cwd);
133
164
  const projectRaw = await readJson(appProjectPath(root, 'config.json'));
134
165
  const trust = await projectTrustStatus(root);
135
166
  const project = trust.trusted ? projectRaw : sanitizeUntrustedProjectConfig(projectRaw);
136
167
  const envConfig = {};
137
- if (process.env[BRAND.modelEnvVar])
138
- envConfig.model = process.env[BRAND.modelEnvVar];
168
+ const envModel = trimmedString(process.env[BRAND.modelEnvVar]);
169
+ if (envModel)
170
+ envConfig.model = envModel;
139
171
  const cleanOverrides = {};
140
172
  for (const [k, v] of Object.entries(overrides)) {
141
173
  if (v !== undefined)
142
174
  cleanOverrides[k] = v;
143
175
  }
144
176
  const merged = { ...global, ...project, ...envConfig, ...cleanOverrides };
145
- const config = ConfigSchema.parse(merged);
177
+ const config = parseConfigGraceful(merged);
146
178
  // pricing override: config.pricing + env SANOOK_PRICING (JSON) → ลงทะเบียนเข้า cost table
147
179
  registerPricing(config.pricing);
148
180
  registerPricing(parseEnvPricing());
@@ -181,41 +213,41 @@ export function parsePricingOverride(raw) {
181
213
  /** ครั้งแรกที่รัน (ยังไม่มี global config) → ต้องทำ setup wizard */
182
214
  export async function isFirstRun() {
183
215
  try {
184
- await readFile(CONFIG_PATH, 'utf8');
216
+ await readFile(globalConfigPath(), 'utf8');
185
217
  return false;
186
218
  }
187
219
  catch {
188
220
  return true;
189
221
  }
190
222
  }
191
- /** บันทึก global config (model/provider ที่เลือกตอน setup) */
223
+ /** บันทึก global config (model/provider/locale ที่เลือกตอน setup) */
192
224
  export async function saveGlobalConfig(cfg) {
193
- await mkdir(CONFIG_DIR, { recursive: true });
194
- const existing = await readJson(CONFIG_PATH);
195
- await writeFile(CONFIG_PATH, `${JSON.stringify({ ...existing, ...cfg }, null, 2)}\n`, { mode: 0o600 });
196
- await chmod(CONFIG_PATH, 0o600).catch(() => { });
225
+ await mkdir(configHomeDir(), { recursive: true });
226
+ const existing = await readJson(globalConfigPath());
227
+ await writeFile(globalConfigPath(), `${JSON.stringify({ ...existing, ...cfg }, null, 2)}\n`, { mode: 0o600 });
228
+ await chmod(globalConfigPath(), 0o600).catch(() => { });
197
229
  }
198
230
  /** บันทึก path ของ second-brain workspace ลง global config (merge — ไม่ทับ field อื่น) */
199
231
  export async function saveBrainPath(path) {
200
- await mkdir(CONFIG_DIR, { recursive: true });
201
- const existing = await readJson(CONFIG_PATH);
202
- await writeFile(CONFIG_PATH, `${JSON.stringify({ ...existing, brainPath: path }, null, 2)}\n`, { mode: 0o600 });
203
- await chmod(CONFIG_PATH, 0o600).catch(() => { });
232
+ await mkdir(configHomeDir(), { recursive: true });
233
+ const existing = await readJson(globalConfigPath());
234
+ await writeFile(globalConfigPath(), `${JSON.stringify({ ...existing, brainPath: path }, null, 2)}\n`, { mode: 0o600 });
235
+ await chmod(globalConfigPath(), 0o600).catch(() => { });
204
236
  }
205
237
  /** อ่าน config.json ดิบ (ไม่ apply default/schema) — สำหรับ `sanook config` */
206
238
  export async function readGlobalConfigRaw() {
207
- return readJson(CONFIG_PATH);
239
+ return readJson(globalConfigPath());
208
240
  }
209
241
  /** path ของ auth.json (ใช้โชว์ใน CLI เท่านั้น; ห้าม print raw secret) */
210
242
  export function authConfigPath() {
211
- return AUTH_PATH;
243
+ return authPath();
212
244
  }
213
245
  function isSafeAuthEnvVarName(name) {
214
246
  return AUTH_ENV_VAR_RE.test(name) && !RESERVED_AUTH_KEYS.has(name);
215
247
  }
216
248
  /** อ่าน auth.json ดิบแบบกรองเฉพาะ string values — caller ต้อง redact ก่อนโชว์ */
217
249
  export async function readStoredAuthRaw() {
218
- const raw = await readJson(AUTH_PATH);
250
+ const raw = await readJson(authPath());
219
251
  const auth = {};
220
252
  for (const [k, v] of Object.entries(raw)) {
221
253
  if (isSafeAuthEnvVarName(k) && typeof v === 'string')
@@ -225,44 +257,44 @@ export async function readStoredAuthRaw() {
225
257
  }
226
258
  /** merge patch ลง config.json (สำหรับ `sanook config set`) */
227
259
  export async function patchGlobalConfig(patch) {
228
- await mkdir(CONFIG_DIR, { recursive: true });
229
- const existing = await readJson(CONFIG_PATH);
230
- await writeFile(CONFIG_PATH, `${JSON.stringify({ ...existing, ...patch }, null, 2)}\n`, { mode: 0o600 });
231
- await chmod(CONFIG_PATH, 0o600).catch(() => { });
260
+ await mkdir(configHomeDir(), { recursive: true });
261
+ const existing = await readJson(globalConfigPath());
262
+ await writeFile(globalConfigPath(), `${JSON.stringify({ ...existing, ...patch }, null, 2)}\n`, { mode: 0o600 });
263
+ await chmod(globalConfigPath(), 0o600).catch(() => { });
232
264
  }
233
265
  /** บันทึก API key ลง ~/.sanook/auth.json (chmod 0600) + set env ทันทีสำหรับ session นี้ */
234
266
  export async function saveKey(envVar, key) {
235
267
  if (!isSafeAuthEnvVarName(envVar))
236
268
  throw new Error(`env var ไม่ถูกต้อง: ${envVar}`);
237
- await mkdir(CONFIG_DIR, { recursive: true });
269
+ await mkdir(configHomeDir(), { recursive: true });
238
270
  const auth = await readStoredAuthRaw();
239
271
  auth[envVar] = key;
240
- await writeFile(AUTH_PATH, `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
241
- await chmod(AUTH_PATH, 0o600); // เจ้าของอ่าน/เขียนเท่านั้น
272
+ await writeFile(authPath(), `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
273
+ await chmod(authPath(), 0o600); // เจ้าของอ่าน/เขียนเท่านั้น
242
274
  process.env[envVar] = key;
243
275
  }
244
276
  /** ลบ key ที่ Sanook เก็บไว้ใน auth.json (ไม่แตะ env จริงของ shell ภายนอก) */
245
277
  export async function removeStoredKey(envVar) {
246
278
  if (!isSafeAuthEnvVarName(envVar))
247
279
  return false;
248
- await mkdir(CONFIG_DIR, { recursive: true });
280
+ await mkdir(configHomeDir(), { recursive: true });
249
281
  const auth = await readStoredAuthRaw();
250
282
  if (!Object.prototype.hasOwnProperty.call(auth, envVar))
251
283
  return false;
252
284
  delete auth[envVar];
253
- await writeFile(AUTH_PATH, `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
254
- await chmod(AUTH_PATH, 0o600).catch(() => { });
285
+ await writeFile(authPath(), `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
286
+ await chmod(authPath(), 0o600).catch(() => { });
255
287
  delete process.env[envVar];
256
288
  return true;
257
289
  }
258
290
  /** ล้าง auth.json ที่ Sanook เก็บไว้ทั้งหมด */
259
291
  export async function clearStoredAuth() {
260
- await mkdir(CONFIG_DIR, { recursive: true });
292
+ await mkdir(configHomeDir(), { recursive: true });
261
293
  const auth = await readStoredAuthRaw();
262
294
  for (const envVar of Object.keys(auth))
263
295
  delete process.env[envVar];
264
- await writeFile(AUTH_PATH, '{}\n', { mode: 0o600 });
265
- await chmod(AUTH_PATH, 0o600).catch(() => { });
296
+ await writeFile(authPath(), '{}\n', { mode: 0o600 });
297
+ await chmod(authPath(), 0o600).catch(() => { });
266
298
  }
267
299
  /** โหลด key จาก auth.json เข้า env ตอน boot (ไม่ override env ที่ตั้งไว้แล้ว) */
268
300
  export async function loadKeysIntoEnv() {
@@ -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
+ }
package/dist/cost.js CHANGED
@@ -12,6 +12,14 @@ export const PRICING = {
12
12
  'openai:gpt-5.5': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 },
13
13
  'openai:gpt-5.4-mini': { input: 0.25, output: 2, cacheWrite: 0.25, cacheRead: 0.025 },
14
14
  'openai:gpt-5.3-codex': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 },
15
+ // OpenAI Codex delegate (ChatGPT plan — token counts are real; cost is estimated from API list price)
16
+ 'codex:gpt-5.5': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 },
17
+ 'codex:gpt-5.4': { input: 2.5, output: 15, cacheWrite: 2.5, cacheRead: 0.25 },
18
+ 'codex:gpt-5.4-mini': { input: 0.25, output: 2, cacheWrite: 0.25, cacheRead: 0.025 },
19
+ 'codex:gpt-5.3-codex': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 },
20
+ 'codex:gpt-5.2-codex': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 },
21
+ 'codex:gpt-5-codex': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 },
22
+ 'codex:gpt-5.3-codex-spark': { input: 0.25, output: 2, cacheWrite: 0.25, cacheRead: 0.025 },
15
23
  // Google Gemini (≤200k context tier)
16
24
  'google:gemini-2.5-pro': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.31 },
17
25
  'google:gemini-2.5-flash': { input: 0.3, output: 2.5, cacheWrite: 0.3, cacheRead: 0.075 },
@@ -147,4 +155,16 @@ export class CostMeter {
147
155
  const budget = this.budgetUsd != null ? ` / budget $${this.budgetUsd}` : '';
148
156
  return `tokens: ${total} (in ${this.inTok} · out ${this.outTok} · cache-read ${this.cacheReadTok} · cache-write ${this.cacheWriteTok}) · cost ${cost}${budget}`;
149
157
  }
158
+ snapshot() {
159
+ return {
160
+ specKey: this.specKey,
161
+ inputTokens: this.inTok,
162
+ outputTokens: this.outTok,
163
+ cacheReadTokens: this.cacheReadTok,
164
+ cacheWriteTokens: this.cacheWriteTok,
165
+ totalTokens: this.inTok + this.outTok + this.cacheReadTok + this.cacheWriteTok,
166
+ costUsd: this.spent,
167
+ hasPricing: this.hasPricing,
168
+ };
169
+ }
150
170
  }