sanook-cli 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +83 -5
  3. package/README.md +240 -23
  4. package/README.th.md +87 -6
  5. package/dist/approval.js +6 -0
  6. package/dist/bin.js +3045 -210
  7. package/dist/brain-context.js +223 -0
  8. package/dist/brain-doctor.js +318 -0
  9. package/dist/brain-eval.js +186 -0
  10. package/dist/brain-final.js +371 -0
  11. package/dist/brain-review.js +382 -0
  12. package/dist/brain.js +12 -1
  13. package/dist/brand.js +1 -1
  14. package/dist/cli-args.js +152 -0
  15. package/dist/cli-option-values.js +16 -0
  16. package/dist/commands.js +172 -13
  17. package/dist/compaction.js +96 -11
  18. package/dist/config.js +118 -28
  19. package/dist/context-compression.js +191 -0
  20. package/dist/cost.js +49 -15
  21. package/dist/first-run.js +21 -0
  22. package/dist/gateway/auth.js +37 -8
  23. package/dist/gateway/bluebubbles.js +205 -0
  24. package/dist/gateway/config.js +929 -0
  25. package/dist/gateway/deliver.js +357 -0
  26. package/dist/gateway/discord.js +124 -0
  27. package/dist/gateway/email.js +472 -0
  28. package/dist/gateway/googlechat.js +207 -0
  29. package/dist/gateway/homeassistant.js +256 -0
  30. package/dist/gateway/ledger.js +18 -0
  31. package/dist/gateway/line.js +171 -0
  32. package/dist/gateway/lock.js +3 -1
  33. package/dist/gateway/matrix.js +366 -0
  34. package/dist/gateway/mattermost.js +322 -0
  35. package/dist/gateway/ntfy.js +218 -0
  36. package/dist/gateway/schedule.js +31 -4
  37. package/dist/gateway/serve.js +267 -7
  38. package/dist/gateway/server.js +253 -19
  39. package/dist/gateway/service.js +224 -0
  40. package/dist/gateway/session.js +343 -0
  41. package/dist/gateway/signal.js +351 -0
  42. package/dist/gateway/slack.js +124 -0
  43. package/dist/gateway/sms.js +169 -0
  44. package/dist/gateway/targets.js +576 -0
  45. package/dist/gateway/teams.js +106 -0
  46. package/dist/gateway/telegram.js +38 -15
  47. package/dist/gateway/webhooks.js +220 -0
  48. package/dist/gateway/whatsapp.js +230 -0
  49. package/dist/hooks.js +13 -2
  50. package/dist/insights-args.js +35 -0
  51. package/dist/insights.js +86 -0
  52. package/dist/loop.js +123 -24
  53. package/dist/lsp/index.js +23 -5
  54. package/dist/mcp-registry.js +350 -0
  55. package/dist/mcp-server.js +1 -1
  56. package/dist/mcp.js +44 -6
  57. package/dist/memory.js +100 -33
  58. package/dist/orchestrate.js +49 -19
  59. package/dist/personality.js +58 -0
  60. package/dist/providers/codex.js +86 -38
  61. package/dist/providers/keys.js +1 -1
  62. package/dist/providers/models.js +22 -6
  63. package/dist/providers/registry.js +38 -49
  64. package/dist/search/chunk.js +7 -8
  65. package/dist/search/cli.js +75 -0
  66. package/dist/search/embed-store.js +3 -0
  67. package/dist/search/indexer.js +44 -1
  68. package/dist/search/store.js +23 -1
  69. package/dist/session.js +93 -7
  70. package/dist/skill-install.js +29 -12
  71. package/dist/support-dump.js +175 -0
  72. package/dist/tools/edit.js +45 -15
  73. package/dist/tools/git.js +10 -5
  74. package/dist/tools/homeassistant.js +106 -0
  75. package/dist/tools/index.js +5 -0
  76. package/dist/tools/list.js +19 -6
  77. package/dist/tools/permission.js +923 -9
  78. package/dist/tools/read.js +16 -4
  79. package/dist/tools/schedule.js +19 -3
  80. package/dist/tools/search.js +217 -13
  81. package/dist/tools/task.js +18 -7
  82. package/dist/tools/timeout.js +21 -3
  83. package/dist/trust.js +11 -1
  84. package/dist/ui/app.js +57 -11
  85. package/dist/ui/brain-wizard.js +2 -2
  86. package/dist/ui/history.js +37 -5
  87. package/dist/ui/mentions.js +3 -2
  88. package/dist/ui/render.js +55 -15
  89. package/dist/ui/setup.js +107 -10
  90. package/dist/update.js +24 -11
  91. package/dist/worktree.js +175 -4
  92. package/package.json +4 -4
  93. package/second-brain/AGENTS.md +6 -4
  94. package/second-brain/CLAUDE.md +7 -1
  95. package/second-brain/Evals/_Index.md +10 -2
  96. package/second-brain/Evals/quality-ledger.md +9 -1
  97. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  98. package/second-brain/GEMINI.md +5 -4
  99. package/second-brain/Home.md +1 -1
  100. package/second-brain/Projects/_Index.md +3 -1
  101. package/second-brain/Projects/sanook-cli/_Index.md +26 -0
  102. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +156 -0
  103. package/second-brain/README.md +1 -1
  104. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  105. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  106. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  107. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  108. package/second-brain/Research/_Index.md +6 -1
  109. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  110. package/second-brain/Reviews/_Index.md +1 -1
  111. package/second-brain/Runbooks/_Index.md +6 -1
  112. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  113. package/second-brain/SANOOK.md +45 -0
  114. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  115. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  116. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  117. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  118. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  119. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  120. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  121. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  122. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  123. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  124. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  125. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  126. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  127. package/second-brain/Sessions/_Index.md +15 -1
  128. package/second-brain/Shared/AI-Context-Index.md +22 -0
  129. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  130. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  131. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  132. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  133. package/second-brain/Shared/Operating-State/current-state.md +22 -3
  134. package/second-brain/Shared/Scripts/_Index.md +3 -1
  135. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  136. package/second-brain/Shared/Tech-Standards/_Index.md +4 -1
  137. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  138. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  139. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  140. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  141. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  142. package/second-brain/Templates/_Index.md +9 -0
  143. package/second-brain/Templates/final-lite.md +111 -0
  144. package/second-brain/Templates/final.md +231 -0
  145. package/second-brain/Vault Structure Map.md +2 -1
  146. package/skills/structured-output-llm/SKILL.md +1 -1
package/dist/commands.js CHANGED
@@ -1,29 +1,42 @@
1
1
  import { readdir, readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { PROVIDERS, parseSpec } from './providers/registry.js';
3
+ import { canonicalSpec, consoleUrl, hasUsableEnvKey, PROVIDERS, parseSpec } from './providers/registry.js';
4
4
  import { appHomePath, BRAND } from './brand.js';
5
5
  import { parseFrontmatter } from './skills.js';
6
6
  import { projectConfigPathIfTrusted } from './trust.js';
7
+ import { normalizePersonalityName, personalityListText } from './personality.js';
8
+ import { parseInsightsArgs } from './insights-args.js';
7
9
  const HELP_TEXT = `คำสั่ง:
8
10
  /help แสดงคำสั่งทั้งหมด
11
+ /new, /reset เริ่มบทสนทนาใหม่
12
+ /status ดูสถานะ session ปัจจุบัน
9
13
  /model [spec] ดู/เปลี่ยน model (เช่น /model opus, /model openai:gpt-5)
14
+ /personality [name]
15
+ ดู/ตั้ง personality overlay
16
+ /platforms ดู providers + messaging platforms ที่รองรับ
10
17
  /tools ดู tools ที่ agent ใช้ได้
11
18
  /skills ดูจำนวน skills (จัดการ: ${BRAND.cliName} skill list)
12
19
  /diff ดู git diff (สิ่งที่ agent แก้ในรอบนี้)
20
+ /retry รัน prompt ล่าสุดอีกครั้ง
21
+ /stop หยุด turn ที่กำลังรัน
13
22
  /undo stash การแก้ไฟล์ล่าสุด (กู้คืนด้วย git stash pop)
14
23
  /rewind ย้อนกลับ 1 turn (คืนไฟล์ git + ตัดบทสนทนา, recoverable)
15
- /cost ดู token + cost รอบล่าสุด
24
+ /cost, /usage ดู token + cost รอบล่าสุด
25
+ /insights [--days N] [--all]
26
+ ดู usage/session insights ในเครื่อง
16
27
  ↑/↓ ประวัติ · @ไฟล์ แนบ context/รูป · \\ ลงท้าย = บรรทัดใหม่
17
28
  /clear ล้าง conversation (เริ่มใหม่)
18
- /compact บีบ context (truncate · หรือ summarize ถ้าตั้ง compaction)
29
+ /compact, /compress
30
+ บีบ context (truncate · หรือ summarize ถ้าตั้ง compaction)
19
31
  /quit ออก
20
32
 
21
33
  นอก REPL (พิมพ์ใน shell):
22
- ${BRAND.cliName} search "<q>" · index · brain init · serve · mcp serve · config set <k> <v>
34
+ ${BRAND.cliName} search "<q>" · index · brain init · brain context · brain eval · brain review · brain final · serve · mcp serve · config set <k> <v>
23
35
  ดูทั้งหมด: ${BRAND.cliName} --help
24
36
 
25
37
  custom commands:
26
- ~/.sanook/commands/<name>.md และ .sanook/commands/<name>.md (project ต้อง trust ก่อน)`;
38
+ ~/.sanook/commands/<name>.md และ .sanook/commands/<name>.md (project ต้อง trust ก่อน)
39
+ args: ใช้ $ARGUMENTS หรือ {{ args }}; ถ้าไม่มี placeholder จะ append args ต่อท้าย`;
27
40
  const TOOLS_LIST = [
28
41
  'read_file (offset/limit) write_file edit_file (replace_all) list_dir glob grep run_bash',
29
42
  'git_status git_diff git_log git_commit',
@@ -32,6 +45,24 @@ const TOOLS_LIST = [
32
45
  'task task_parallel task_spawn task_collect task_cancel task_status ← sub-agent (ขนาน/background)',
33
46
  'diagnostics ← type error/lint จาก language server (LSP)',
34
47
  ].join('\n ');
48
+ const MESSAGING_PLATFORMS = [
49
+ 'telegram',
50
+ 'discord',
51
+ 'slack',
52
+ 'mattermost',
53
+ 'homeassistant',
54
+ 'email',
55
+ 'line',
56
+ 'sms',
57
+ 'ntfy',
58
+ 'signal',
59
+ 'whatsapp',
60
+ 'matrix',
61
+ 'googlechat',
62
+ 'bluebubbles',
63
+ 'teams',
64
+ 'webhooks',
65
+ ];
35
66
  export function parseSlashInvocation(input) {
36
67
  const trimmed = input.trim();
37
68
  if (!trimmed.startsWith('/'))
@@ -39,7 +70,10 @@ export function parseSlashInvocation(input) {
39
70
  const match = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(trimmed);
40
71
  if (!match)
41
72
  return null;
42
- return { name: match[1].toLowerCase(), args: match[2] ?? '' };
73
+ const name = match[1].toLowerCase();
74
+ if (name !== '?' && !isValidCommandName(name))
75
+ return null;
76
+ return { name, args: match[2] ?? '' };
43
77
  }
44
78
  /** /model (ไม่มี arg) — โชว์ model ปัจจุบัน + ตัวเลือกของ provider นั้น (alias จาก registry) */
45
79
  function modelMenu(current) {
@@ -60,19 +94,81 @@ function modelMenu(current) {
60
94
  .filter(Boolean)
61
95
  .join('\n');
62
96
  }
97
+ function missingKeyHint(provider) {
98
+ const cfg = PROVIDERS[provider];
99
+ if (!cfg?.requiresKey || hasUsableEnvKey(provider))
100
+ return undefined;
101
+ const url = consoleUrl(provider);
102
+ const lines = [
103
+ `⚠ ยังไม่มี API key ของ ${cfg.label} (${cfg.envVar}) — model นี้จะยังรันไม่ได้จนกว่าจะตั้ง key`,
104
+ url ? ` • เอา key ที่: ${url}` : undefined,
105
+ ` • ตั้ง: export ${cfg.envVar}="..." หรือรัน ${BRAND.cliName} เพื่อเข้า setup wizard`,
106
+ ].filter(Boolean);
107
+ if (provider === 'openai') {
108
+ lines.push(' • ถ้าต้องการใช้ ChatGPT plan ไม่ใช้ API key: /model codex แล้วรัน codex login');
109
+ }
110
+ return lines.join('\n');
111
+ }
112
+ function platformMenu() {
113
+ return [
114
+ `providers: ${Object.keys(PROVIDERS).join(' · ')}`,
115
+ `messaging: ${MESSAGING_PLATFORMS.join(' · ')}`,
116
+ `setup: ${BRAND.cliName} setup หรือ ${BRAND.cliName} gateway setup <platform>`,
117
+ ].join('\n');
118
+ }
119
+ function statusMenu(ctx) {
120
+ const { provider } = parseSpec(ctx.model);
121
+ const cfg = PROVIDERS[provider];
122
+ return [
123
+ `session: REPL`,
124
+ `model: ${ctx.model}`,
125
+ `provider: ${cfg?.label ?? provider}`,
126
+ `usage: ${ctx.costSummary ?? '(ยังไม่มี usage รอบนี้)'}`,
127
+ `platforms: พิมพ์ /platforms`,
128
+ `system status: ${BRAND.cliName} status`,
129
+ ].join('\n');
130
+ }
131
+ function modelChange(spec) {
132
+ const canonical = canonicalSpec(spec);
133
+ const { provider, model } = parseSpec(canonical);
134
+ if (!PROVIDERS[provider]) {
135
+ return {
136
+ handled: true,
137
+ message: `provider ไม่รองรับ: "${provider}" — รองรับ: ${Object.keys(PROVIDERS).join(' · ')}`,
138
+ };
139
+ }
140
+ if (!model) {
141
+ return {
142
+ handled: true,
143
+ message: `model spec ไม่ครบ: "${spec}" — ใช้ /model <alias> หรือ /model <provider:model>`,
144
+ };
145
+ }
146
+ const hint = missingKeyHint(provider);
147
+ return {
148
+ handled: true,
149
+ modelChange: canonical,
150
+ message: [`เปลี่ยน model → ${canonical}`, hint].filter(Boolean).join('\n'),
151
+ };
152
+ }
63
153
  /** parse input — ถ้าขึ้นต้น / = slash command, ไม่งั้น handled=false (ส่งเข้า agent) */
64
154
  export function parseCommand(input, ctx) {
65
155
  const trimmed = input.trim();
66
156
  if (!trimmed.startsWith('/'))
67
157
  return { handled: false };
68
- const [cmd, ...args] = trimmed.slice(1).split(/\s+/);
158
+ const [rawCmd, ...args] = trimmed.slice(1).split(/\s+/);
159
+ const cmd = rawCmd.toLowerCase();
69
160
  switch (cmd) {
70
161
  case 'help':
71
162
  case '?':
72
163
  return { handled: true, action: 'help', message: HELP_TEXT };
73
164
  case 'clear':
165
+ case 'new':
166
+ case 'reset':
74
167
  return { handled: true, action: 'clear', message: 'ล้าง conversation แล้ว' };
168
+ case 'status':
169
+ return { handled: true, message: statusMenu(ctx) };
75
170
  case 'compact':
171
+ case 'compress':
76
172
  return { handled: true, action: 'compact', message: 'บีบ context แล้ว' };
77
173
  case 'quit':
78
174
  case 'exit':
@@ -80,17 +176,46 @@ export function parseCommand(input, ctx) {
80
176
  case 'model':
81
177
  if (!args[0])
82
178
  return { handled: true, message: modelMenu(ctx.model) };
83
- return { handled: true, modelChange: args[0], message: `เปลี่ยน model → ${args[0]}` };
179
+ return modelChange(args[0]);
180
+ case 'personality': {
181
+ const raw = args.join(' ').trim();
182
+ if (!raw)
183
+ return { handled: true, message: personalityListText() };
184
+ const name = normalizePersonalityName(raw);
185
+ if (!name)
186
+ return { handled: true, message: `ไม่รู้จัก personality: ${raw}\n\n${personalityListText()}` };
187
+ return {
188
+ handled: true,
189
+ action: 'personality',
190
+ personalityChange: name === 'none' ? '' : name,
191
+ message: name === 'none' ? 'ปิด personality overlay แล้ว' : `ตั้ง personality → ${name}`,
192
+ };
193
+ }
84
194
  case 'tools':
85
195
  return { handled: true, message: `tools ที่ agent ใช้ได้ (+ MCP ที่ตั้งไว้):\n ${TOOLS_LIST}` };
196
+ case 'platforms':
197
+ return { handled: true, message: platformMenu() };
86
198
  case 'skills':
87
199
  return { handled: true, message: `skills โหลดจาก built-in + ${appHomePath('skills')} — จัดการด้วย "${BRAND.cliName} skill list/add/remove"` };
88
200
  case 'diff':
89
201
  return { handled: true, action: 'diff' };
202
+ case 'retry':
203
+ return { handled: true, action: 'retry' };
204
+ case 'stop':
205
+ return { handled: true, action: 'stop', message: 'ไม่มี turn ที่กำลังทำงาน' };
90
206
  case 'undo':
91
207
  return { handled: true, action: 'undo' };
208
+ case 'rewind':
209
+ return { handled: true, action: 'rewind' };
92
210
  case 'cost':
211
+ case 'usage':
93
212
  return { handled: true, message: ctx.costSummary ?? '(ยังไม่มี usage รอบนี้)' };
213
+ case 'insights': {
214
+ const parsed = parseInsightsArgs(args);
215
+ if (parsed === null)
216
+ return { handled: true, message: 'ใช้: /insights [--days N] [--all] (N ต้องเป็นจำนวนวันบวก)' };
217
+ return { handled: true, action: 'insights', insightsDays: parsed.days, insightsAll: parsed.all };
218
+ }
94
219
  default:
95
220
  return { handled: true, message: `ไม่รู้จักคำสั่ง /${cmd} — พิมพ์ /help` };
96
221
  }
@@ -99,11 +224,44 @@ export function parseCommand(input, ctx) {
99
224
  // ไฟล์ markdown (frontmatter optional) = prompt template ที่ส่งเข้า agent. $ARGUMENTS = ส่วนหลังชื่อคำสั่ง
100
225
  // (เลียน Claude Code .claude/commands) — global ~/.sanook/commands + project .sanook/commands (project ทับ)
101
226
  export const BUILTIN_COMMANDS = new Set([
102
- 'help', '?', 'clear', 'compact', 'quit', 'exit', 'model', 'tools', 'skills', 'diff', 'undo', 'rewind', 'cost',
227
+ 'help',
228
+ '?',
229
+ 'clear',
230
+ 'new',
231
+ 'reset',
232
+ 'status',
233
+ 'compact',
234
+ 'compress',
235
+ 'quit',
236
+ 'exit',
237
+ 'model',
238
+ 'personality',
239
+ 'platforms',
240
+ 'tools',
241
+ 'skills',
242
+ 'diff',
243
+ 'retry',
244
+ 'stop',
245
+ 'undo',
246
+ 'rewind',
247
+ 'cost',
248
+ 'usage',
249
+ 'insights',
103
250
  ]);
104
251
  function isValidCommandName(name) {
105
252
  return /^[a-z0-9][a-z0-9-]{0,40}$/.test(name);
106
253
  }
254
+ function compareCommandFiles(a, b) {
255
+ const an = a.toLowerCase();
256
+ const bn = b.toLowerCase();
257
+ if (an !== bn)
258
+ return an.localeCompare(bn);
259
+ if (a === an && b !== bn)
260
+ return 1;
261
+ if (a !== an && b === bn)
262
+ return -1;
263
+ return a.localeCompare(b);
264
+ }
107
265
  /** scan custom commands จาก global + project (project override). ข้าม built-in ชื่อซ้ำ */
108
266
  export async function loadCustomCommands(cwd = process.cwd()) {
109
267
  const out = new Map();
@@ -119,10 +277,11 @@ export async function loadCustomCommands(cwd = process.cwd()) {
119
277
  catch {
120
278
  continue; // ไม่มีโฟลเดอร์ = ข้าม
121
279
  }
122
- for (const f of files) {
123
- if (!f.endsWith('.md'))
280
+ for (const f of files.sort(compareCommandFiles)) {
281
+ const normalizedFile = f.toLowerCase();
282
+ if (!normalizedFile.endsWith('.md'))
124
283
  continue;
125
- const name = f.slice(0, -3).toLowerCase();
284
+ const name = normalizedFile.slice(0, -3);
126
285
  if (!isValidCommandName(name) || BUILTIN_COMMANDS.has(name))
127
286
  continue;
128
287
  try {
@@ -140,7 +299,7 @@ export async function loadCustomCommands(cwd = process.cwd()) {
140
299
  export function expandCustomCommand(cmd, args) {
141
300
  const a = args.trim();
142
301
  if (/\$ARGUMENTS|\{\{\s*args\s*\}\}/.test(cmd.body)) {
143
- return cmd.body.replace(/\$ARGUMENTS|\{\{\s*args\s*\}\}/g, a);
302
+ return cmd.body.replace(/\$ARGUMENTS|\{\{\s*args\s*\}\}/g, () => a);
144
303
  }
145
304
  return a ? `${cmd.body}\n\n${a}` : cmd.body;
146
305
  }
@@ -1,6 +1,42 @@
1
+ import { selectiveCompressText } from './context-compression.js';
1
2
  const TRUNC_HEAD = 400;
2
3
  const TRUNC_TAIL = 600;
3
4
  const CHARS_PER_TOKEN = 4; // ประมาณคร่าวๆ (จริง ~3.5-4 ต่อ token)
5
+ const SELECTIVE_TOOL_TARGET_CHARS = 6_000;
6
+ const SELECTIVE_TOOL_MIN_CHARS = 8_000;
7
+ function textFromMessageContent(content) {
8
+ if (typeof content === 'string')
9
+ return content;
10
+ if (!Array.isArray(content))
11
+ return '';
12
+ return content
13
+ .map((part) => {
14
+ if (typeof part === 'object' && part && 'type' in part && part.type === 'text' && 'text' in part && typeof part.text === 'string')
15
+ return part.text;
16
+ return '';
17
+ })
18
+ .filter(Boolean)
19
+ .join('\n');
20
+ }
21
+ function latestUserText(messages) {
22
+ for (let i = messages.length - 1; i >= 0; i--) {
23
+ if (messages[i].role !== 'user')
24
+ continue;
25
+ const text = textFromMessageContent(messages[i].content).trim();
26
+ if (text)
27
+ return text;
28
+ }
29
+ return undefined;
30
+ }
31
+ function adaptiveStaleTarget(baseTarget, rank, count) {
32
+ if (count <= 1)
33
+ return baseTarget;
34
+ const recency = rank / Math.max(1, count - 1); // 0 = oldest, 1 = newest stale
35
+ return Math.max(1_500, Math.floor(baseTarget * (0.35 + 0.65 * recency)));
36
+ }
37
+ function adaptiveMinChars(targetChars, baseMinChars) {
38
+ return Math.min(baseMinChars, Math.max(targetChars + 1_000, Math.floor(targetChars * 1.45)));
39
+ }
4
40
  /** ตัดข้อความยาว เก็บหัว (intent) + ท้าย (error/result) */
5
41
  export function truncateText(s) {
6
42
  if (s.length <= TRUNC_HEAD + TRUNC_TAIL + 40)
@@ -16,23 +52,72 @@ export function truncateText(s) {
16
52
  */
17
53
  export function pruneToolResults(messages, keepTail = 4) {
18
54
  const cut = Math.max(0, messages.length - keepTail);
19
- return messages.map((m, i) => {
55
+ let changed = false;
56
+ const out = messages.map((m, i) => {
20
57
  if (i >= cut)
21
58
  return m;
22
59
  if (m.role !== 'tool' || !Array.isArray(m.content))
23
60
  return m;
24
- return {
25
- ...m,
26
- content: m.content.map((part) => {
27
- if (part.type === 'tool-result' &&
28
- part.output?.type === 'text' &&
29
- typeof part.output.value === 'string') {
30
- return { ...part, output: { ...part.output, value: truncateText(part.output.value) } };
61
+ const content = m.content.map((part) => {
62
+ if (part.type === 'tool-result' &&
63
+ part.output?.type === 'text' &&
64
+ typeof part.output.value === 'string') {
65
+ const compressed = selectiveCompressText(part.output.value, {
66
+ targetChars: SELECTIVE_TOOL_TARGET_CHARS,
67
+ minChars: SELECTIVE_TOOL_MIN_CHARS,
68
+ });
69
+ if (compressed.changed) {
70
+ changed = true;
71
+ return { ...part, output: { ...part.output, value: compressed.text } };
72
+ }
73
+ const truncated = truncateText(part.output.value);
74
+ if (truncated !== part.output.value) {
75
+ changed = true;
76
+ return { ...part, output: { ...part.output, value: truncated } };
77
+ }
78
+ }
79
+ return part;
80
+ });
81
+ return content === m.content ? m : { ...m, content };
82
+ });
83
+ return changed ? out : messages;
84
+ }
85
+ /**
86
+ * Per-step token optimizer (zero LLM cost).
87
+ * Compresses stale, very large tool results before each model request while keeping the latest tail full.
88
+ */
89
+ export function selectivelyCompressStaleToolResults(messages, keepTail = 6, targetChars = SELECTIVE_TOOL_TARGET_CHARS, minChars = SELECTIVE_TOOL_MIN_CHARS, query = latestUserText(messages)) {
90
+ const cut = Math.max(0, messages.length - keepTail);
91
+ const staleToolIndexes = messages
92
+ .map((m, i) => ({ m, i }))
93
+ .filter(({ m, i }) => i < cut && m.role === 'tool' && Array.isArray(m.content))
94
+ .map(({ i }) => i);
95
+ const rankByIndex = new Map(staleToolIndexes.map((index, rank) => [index, rank]));
96
+ let changed = false;
97
+ const out = messages.map((m, i) => {
98
+ if (i >= cut)
99
+ return m;
100
+ if (m.role !== 'tool' || !Array.isArray(m.content))
101
+ return m;
102
+ let messageChanged = false;
103
+ const adaptiveTarget = adaptiveStaleTarget(targetChars, rankByIndex.get(i) ?? 0, staleToolIndexes.length);
104
+ const adaptiveMin = adaptiveMinChars(adaptiveTarget, minChars);
105
+ const content = m.content.map((part) => {
106
+ if (part.type === 'tool-result' &&
107
+ part.output?.type === 'text' &&
108
+ typeof part.output.value === 'string') {
109
+ const compressed = selectiveCompressText(part.output.value, { targetChars: adaptiveTarget, minChars: adaptiveMin, query });
110
+ if (compressed.changed) {
111
+ changed = true;
112
+ messageChanged = true;
113
+ return { ...part, output: { ...part.output, value: compressed.text } };
31
114
  }
32
- return part;
33
- }),
34
- };
115
+ }
116
+ return part;
117
+ });
118
+ return messageChanged ? { ...m, content } : m;
35
119
  });
120
+ return changed ? out : messages;
36
121
  }
37
122
  /** ประมาณ token ของ conversation (chars/4) — ไม่เป๊ะแต่พอใช้ตัดสิน compact */
38
123
  export function estimateTokens(messages) {
package/dist/config.js CHANGED
@@ -7,7 +7,10 @@ import { registerPricing } from './cost.js';
7
7
  export const CONFIG_DIR = appHomePath();
8
8
  const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
9
9
  const AUTH_PATH = join(CONFIG_DIR, 'auth.json'); // API keys (chmod 0600)
10
- export const PricingOverrideSchema = z.record(z.string(), z
10
+ const AUTH_ENV_VAR_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
11
+ const RESERVED_AUTH_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
12
+ const PricingKeySchema = z.string().regex(/^[^:\s]+:\S+$/, 'key ต้องเป็น provider:model');
13
+ export const PricingOverrideSchema = z.record(PricingKeySchema, z
11
14
  .object({
12
15
  input: z.number().finite().nonnegative().optional(),
13
16
  output: z.number().finite().nonnegative().optional(),
@@ -33,39 +36,68 @@ export const ConfigSchema = z.object({
33
36
  cacheTtl: z.enum(['5m', '1h']).catch('5m').default('5m'),
34
37
  // วิธีบีบ context ตอนยาว: 'truncate' (default, zero-LLM) · 'summarize' (ใช้ model ถูกย่อ — จำ context ได้ดีกว่า)
35
38
  compaction: z.enum(['truncate', 'summarize']).catch('truncate').default('truncate'),
39
+ // token reducer: off, local zero-LLM selective compressor, or optional Headroom proxy adapter.
40
+ contextCompression: z.enum(['off', 'selective', 'headroom']).catch('selective').default('selective'),
36
41
  // extended thinking (Anthropic): false/ไม่ตั้ง = ปิด · true = budget default · number = budget tokens
37
42
  thinking: z.union([z.boolean(), z.number().int().positive()]).optional().catch(undefined),
38
43
  // model สำหรับย่อ (compaction=summarize) — ไม่ตั้ง = ใช้ fast-sibling ของ model หลัก (ค่ายเดียวกัน ถูกกว่า)
39
44
  summaryModel: z.string().optional().catch(undefined),
45
+ // model สำหรับ semantic search embeddings (เช่น openai:text-embedding-3-small)
46
+ embeddingModel: z.string().optional().catch(undefined),
47
+ // Hermes-style /personality overlay (stored as a small named prompt)
48
+ personality: z.string().optional().catch(undefined),
40
49
  });
41
50
  const DEFAULT_THINKING_BUDGET = 4096;
51
+ function normalizeThinkingBudget(value) {
52
+ const budget = Math.floor(value);
53
+ return Number.isSafeInteger(budget) && budget > 0 ? budget : undefined;
54
+ }
42
55
  /** parse thinking config (config field หรือ env) → budget tokens (undefined = ปิด) */
43
56
  function parseThinking(v) {
44
- if (typeof v === 'number' && v > 0)
45
- return Math.floor(v);
57
+ if (typeof v === 'number' && Number.isFinite(v))
58
+ return normalizeThinkingBudget(v);
46
59
  if (v === true)
47
60
  return DEFAULT_THINKING_BUDGET;
48
61
  if (typeof v === 'string') {
49
- if (/^\d+$/.test(v))
50
- return Number.parseInt(v, 10);
51
- if (['on', 'true', '1', 'yes'].includes(v.toLowerCase()))
62
+ const clean = v.trim();
63
+ if (/^\d+$/.test(clean))
64
+ return normalizeThinkingBudget(Number(clean));
65
+ if (['on', 'true', '1', 'yes'].includes(clean.toLowerCase()))
52
66
  return DEFAULT_THINKING_BUDGET;
53
67
  }
54
68
  return undefined;
55
69
  }
70
+ function trimmedString(v) {
71
+ if (typeof v !== 'string')
72
+ return undefined;
73
+ const clean = v.trim();
74
+ return clean ? clean : undefined;
75
+ }
76
+ function parseCacheTtl(v) {
77
+ const clean = trimmedString(v);
78
+ return clean === '5m' || clean === '1h' ? clean : undefined;
79
+ }
80
+ function parseCompaction(v) {
81
+ const clean = trimmedString(v);
82
+ return clean === 'truncate' || clean === 'summarize' ? clean : undefined;
83
+ }
84
+ function parseContextCompression(v) {
85
+ const clean = trimmedString(v);
86
+ return clean === 'off' || clean === 'selective' || clean === 'headroom' ? clean : undefined;
87
+ }
56
88
  /**
57
89
  * อ่าน tuning knobs (cache TTL / thinking / compaction / summary model) จาก global config.json
58
- * + env override (SANOOK_CACHE_TTL / SANOOK_THINKING / SANOOK_COMPACTION / SANOOK_SUMMARY_MODEL).
90
+ * + env override (SANOOK_CACHE_TTL / SANOOK_THINKING / SANOOK_COMPACTION / SANOOK_CONTEXT_COMPRESSION / SANOOK_SUMMARY_MODEL).
59
91
  * อ่านตรงจาก config.json (เลี่ยง thread ผ่าน call stack ลึก) — เบา, เรียกครั้งเดียวต่อ turn.
60
92
  */
61
93
  export async function agentTuning() {
62
94
  const raw = await readGlobalConfigRaw();
63
- const envTtl = process.env.SANOOK_CACHE_TTL;
64
- const cacheTtl = envTtl === '1h' || (envTtl !== '5m' && raw.cacheTtl === '1h') ? '1h' : '5m';
65
- const thinkingBudget = parseThinking(process.env.SANOOK_THINKING ?? raw.thinking);
66
- const compaction = (process.env.SANOOK_COMPACTION ?? raw.compaction) === 'summarize' ? 'summarize' : 'truncate';
67
- const summaryModel = process.env.SANOOK_SUMMARY_MODEL ?? (typeof raw.summaryModel === 'string' ? raw.summaryModel : undefined);
68
- return { cacheTtl, thinkingBudget, compaction, summaryModel };
95
+ const cacheTtl = parseCacheTtl(process.env.SANOOK_CACHE_TTL) ?? parseCacheTtl(raw.cacheTtl) ?? '5m';
96
+ const thinkingBudget = parseThinking(trimmedString(process.env.SANOOK_THINKING) ?? raw.thinking);
97
+ const compaction = parseCompaction(process.env.SANOOK_COMPACTION) ?? parseCompaction(raw.compaction) ?? 'truncate';
98
+ const contextCompression = parseContextCompression(process.env.SANOOK_CONTEXT_COMPRESSION) ?? parseContextCompression(raw.contextCompression) ?? 'selective';
99
+ const summaryModel = trimmedString(process.env.SANOOK_SUMMARY_MODEL) ?? trimmedString(raw.summaryModel);
100
+ return { cacheTtl, thinkingBudget, compaction, contextCompression, summaryModel };
69
101
  }
70
102
  async function readJson(path) {
71
103
  try {
@@ -76,9 +108,18 @@ async function readJson(path) {
76
108
  return {}; // ไม่มีไฟล์ / parse ไม่ได้ = ใช้ default
77
109
  }
78
110
  }
111
+ // key ที่ untrusted project ตั้งไม่ได้ (ต้อง `sanook trust` ก่อน):
112
+ // - permissionMode: auto = auto-approve mutation (รัน bash/แก้ไฟล์ไม่ถาม) — อันตรายสุด
113
+ // - budgetUsd: repo อันตรายตั้งสูงๆ = ปิด spend cap ของ user (เปลืองเงินจริง)
114
+ // - pricing: ตั้งราคาปลอม = ทำให้ budget cap ไม่ trigger (ซ่อน cost / bypass cap)
115
+ // (model/maxSteps/embeddingModel ฯลฯ ปล่อยได้ — เป็น preference ที่ user เห็น/override ได้ และตอนนี้ถูกคุมด้วย budget จริงของ user)
116
+ const UNTRUSTED_PROJECT_DENY = new Set(['permissionMode', 'budgetUsd', 'pricing']);
79
117
  function sanitizeUntrustedProjectConfig(cfg) {
80
- const out = { ...cfg };
81
- delete out.permissionMode;
118
+ const out = {};
119
+ for (const [k, v] of Object.entries(cfg)) {
120
+ if (!UNTRUSTED_PROJECT_DENY.has(k))
121
+ out[k] = v;
122
+ }
82
123
  return out;
83
124
  }
84
125
  /**
@@ -113,16 +154,29 @@ function parseEnvPricing() {
113
154
  if (!raw)
114
155
  return undefined;
115
156
  try {
116
- const parsed = JSON.parse(raw);
117
- const res = PricingOverrideSchema.safeParse(parsed);
118
- return res.success ? res.data : undefined;
157
+ return parsePricingOverride(raw);
119
158
  }
120
159
  catch {
121
160
  return undefined; // JSON ไม่ถูก = ข้าม (ไม่ทำให้ boot ล้ม)
122
161
  }
123
162
  }
124
163
  export function parsePricingOverride(raw) {
125
- return PricingOverrideSchema.parse(JSON.parse(raw));
164
+ let parsed;
165
+ try {
166
+ parsed = JSON.parse(raw);
167
+ }
168
+ catch {
169
+ throw new Error('pricing JSON parse ไม่สำเร็จ');
170
+ }
171
+ const res = PricingOverrideSchema.safeParse(parsed);
172
+ if (!res.success) {
173
+ const details = res.error.issues
174
+ .slice(0, 3)
175
+ .map((issue) => `${issue.path.length ? issue.path.join('.') : 'pricing'}: ${issue.message}`)
176
+ .join('; ');
177
+ throw new Error(`pricing schema ไม่ถูกต้อง${details ? ` — ${details}` : ''}`);
178
+ }
179
+ return res.data;
126
180
  }
127
181
  /** ครั้งแรกที่รัน (ยังไม่มี global config) → ต้องทำ setup wizard */
128
182
  export async function isFirstRun() {
@@ -152,6 +206,23 @@ export async function saveBrainPath(path) {
152
206
  export async function readGlobalConfigRaw() {
153
207
  return readJson(CONFIG_PATH);
154
208
  }
209
+ /** path ของ auth.json (ใช้โชว์ใน CLI เท่านั้น; ห้าม print raw secret) */
210
+ export function authConfigPath() {
211
+ return AUTH_PATH;
212
+ }
213
+ function isSafeAuthEnvVarName(name) {
214
+ return AUTH_ENV_VAR_RE.test(name) && !RESERVED_AUTH_KEYS.has(name);
215
+ }
216
+ /** อ่าน auth.json ดิบแบบกรองเฉพาะ string values — caller ต้อง redact ก่อนโชว์ */
217
+ export async function readStoredAuthRaw() {
218
+ const raw = await readJson(AUTH_PATH);
219
+ const auth = {};
220
+ for (const [k, v] of Object.entries(raw)) {
221
+ if (isSafeAuthEnvVarName(k) && typeof v === 'string')
222
+ auth[k] = v;
223
+ }
224
+ return auth;
225
+ }
155
226
  /** merge patch ลง config.json (สำหรับ `sanook config set`) */
156
227
  export async function patchGlobalConfig(patch) {
157
228
  await mkdir(CONFIG_DIR, { recursive: true });
@@ -161,25 +232,44 @@ export async function patchGlobalConfig(patch) {
161
232
  }
162
233
  /** บันทึก API key ลง ~/.sanook/auth.json (chmod 0600) + set env ทันทีสำหรับ session นี้ */
163
234
  export async function saveKey(envVar, key) {
235
+ if (!isSafeAuthEnvVarName(envVar))
236
+ throw new Error(`env var ไม่ถูกต้อง: ${envVar}`);
164
237
  await mkdir(CONFIG_DIR, { recursive: true });
165
- let auth = {};
166
- try {
167
- auth = JSON.parse(await readFile(AUTH_PATH, 'utf8'));
168
- }
169
- catch {
170
- /* ยังไม่มีไฟล์ */
171
- }
238
+ const auth = await readStoredAuthRaw();
172
239
  auth[envVar] = key;
173
240
  await writeFile(AUTH_PATH, `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
174
241
  await chmod(AUTH_PATH, 0o600); // เจ้าของอ่าน/เขียนเท่านั้น
175
242
  process.env[envVar] = key;
176
243
  }
244
+ /** ลบ key ที่ Sanook เก็บไว้ใน auth.json (ไม่แตะ env จริงของ shell ภายนอก) */
245
+ export async function removeStoredKey(envVar) {
246
+ if (!isSafeAuthEnvVarName(envVar))
247
+ return false;
248
+ await mkdir(CONFIG_DIR, { recursive: true });
249
+ const auth = await readStoredAuthRaw();
250
+ if (!Object.prototype.hasOwnProperty.call(auth, envVar))
251
+ return false;
252
+ delete auth[envVar];
253
+ await writeFile(AUTH_PATH, `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
254
+ await chmod(AUTH_PATH, 0o600).catch(() => { });
255
+ delete process.env[envVar];
256
+ return true;
257
+ }
258
+ /** ล้าง auth.json ที่ Sanook เก็บไว้ทั้งหมด */
259
+ export async function clearStoredAuth() {
260
+ await mkdir(CONFIG_DIR, { recursive: true });
261
+ const auth = await readStoredAuthRaw();
262
+ for (const envVar of Object.keys(auth))
263
+ delete process.env[envVar];
264
+ await writeFile(AUTH_PATH, '{}\n', { mode: 0o600 });
265
+ await chmod(AUTH_PATH, 0o600).catch(() => { });
266
+ }
177
267
  /** โหลด key จาก auth.json เข้า env ตอน boot (ไม่ override env ที่ตั้งไว้แล้ว) */
178
268
  export async function loadKeysIntoEnv() {
179
269
  try {
180
- const auth = JSON.parse(await readFile(AUTH_PATH, 'utf8'));
270
+ const auth = await readStoredAuthRaw();
181
271
  for (const [k, v] of Object.entries(auth)) {
182
- if (!process.env[k] && typeof v === 'string')
272
+ if (!process.env[k])
183
273
  process.env[k] = v;
184
274
  }
185
275
  }