sanook-cli 0.5.1 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +148 -10
  3. package/README.md +255 -26
  4. package/README.th.md +95 -7
  5. package/dist/approval.js +13 -0
  6. package/dist/bin.js +3552 -155
  7. package/dist/brain-consolidate.js +335 -0
  8. package/dist/brain-context.js +262 -0
  9. package/dist/brain-doctor.js +318 -0
  10. package/dist/brain-eval.js +186 -0
  11. package/dist/brain-final.js +377 -0
  12. package/dist/brain-metrics.js +277 -0
  13. package/dist/brain-new.js +402 -0
  14. package/dist/brain-pack.js +210 -0
  15. package/dist/brain-repair.js +280 -0
  16. package/dist/brain-review.js +382 -0
  17. package/dist/brain.js +15 -1
  18. package/dist/brand.js +1 -1
  19. package/dist/cli-args.js +190 -0
  20. package/dist/cli-option-values.js +16 -0
  21. package/dist/clipboard.js +65 -0
  22. package/dist/commands.js +266 -27
  23. package/dist/compaction.js +96 -11
  24. package/dist/config.js +149 -33
  25. package/dist/context-compression.js +191 -0
  26. package/dist/context-pack.js +145 -0
  27. package/dist/cost.js +49 -15
  28. package/dist/dashboard/api-helpers.js +87 -0
  29. package/dist/dashboard/server.js +179 -0
  30. package/dist/dashboard/static/app.js +277 -0
  31. package/dist/dashboard/static/index.html +39 -0
  32. package/dist/dashboard/static/styles.css +85 -0
  33. package/dist/diff.js +10 -2
  34. package/dist/first-run.js +21 -0
  35. package/dist/gateway/auth.js +49 -9
  36. package/dist/gateway/bluebubbles.js +205 -0
  37. package/dist/gateway/config.js +929 -0
  38. package/dist/gateway/deliver.js +399 -0
  39. package/dist/gateway/discord.js +124 -0
  40. package/dist/gateway/doctor.js +456 -0
  41. package/dist/gateway/email.js +501 -0
  42. package/dist/gateway/googlechat.js +207 -0
  43. package/dist/gateway/homeassistant.js +256 -0
  44. package/dist/gateway/ledger.js +38 -1
  45. package/dist/gateway/line.js +171 -0
  46. package/dist/gateway/lock.js +3 -1
  47. package/dist/gateway/matrix.js +366 -0
  48. package/dist/gateway/mattermost.js +322 -0
  49. package/dist/gateway/ntfy.js +218 -0
  50. package/dist/gateway/schedule.js +31 -4
  51. package/dist/gateway/serve.js +267 -7
  52. package/dist/gateway/server.js +253 -19
  53. package/dist/gateway/service.js +224 -0
  54. package/dist/gateway/session.js +362 -0
  55. package/dist/gateway/signal.js +351 -0
  56. package/dist/gateway/slack.js +124 -0
  57. package/dist/gateway/sms.js +169 -0
  58. package/dist/gateway/targets.js +576 -0
  59. package/dist/gateway/teams.js +106 -0
  60. package/dist/gateway/telegram.js +38 -15
  61. package/dist/gateway/webhooks.js +220 -0
  62. package/dist/gateway/whatsapp.js +230 -0
  63. package/dist/hooks.js +13 -2
  64. package/dist/hotkeys.js +21 -0
  65. package/dist/i18n/en.js +98 -0
  66. package/dist/i18n/index.js +19 -0
  67. package/dist/i18n/th.js +98 -0
  68. package/dist/i18n/types.js +1 -0
  69. package/dist/insights-args.js +55 -0
  70. package/dist/insights.js +86 -0
  71. package/dist/knowledge.js +55 -29
  72. package/dist/loop.js +157 -29
  73. package/dist/lsp/index.js +23 -5
  74. package/dist/mcp-hub.js +33 -0
  75. package/dist/mcp-registry.js +494 -0
  76. package/dist/mcp-risk.js +71 -0
  77. package/dist/mcp-server.js +1 -1
  78. package/dist/mcp.js +120 -10
  79. package/dist/memory-log.js +90 -0
  80. package/dist/memory-store.js +37 -1
  81. package/dist/memory.js +148 -37
  82. package/dist/model-picker.js +58 -0
  83. package/dist/orchestrate.js +51 -19
  84. package/dist/personality.js +58 -0
  85. package/dist/plan-handoff.js +17 -0
  86. package/dist/polyglot.js +162 -0
  87. package/dist/process-runner.js +96 -0
  88. package/dist/project-init.js +91 -0
  89. package/dist/project-registry.js +143 -0
  90. package/dist/project-scaffold.js +124 -0
  91. package/dist/prompt-size.js +155 -0
  92. package/dist/providers/codex-login.js +138 -0
  93. package/dist/providers/codex.js +89 -43
  94. package/dist/providers/keys.js +22 -1
  95. package/dist/providers/models.js +2 -2
  96. package/dist/providers/registry.js +14 -47
  97. package/dist/search/chunk.js +7 -8
  98. package/dist/search/cli.js +83 -0
  99. package/dist/search/embed-store.js +3 -0
  100. package/dist/search/embedding-config.js +22 -0
  101. package/dist/search/engine.js +2 -13
  102. package/dist/search/indexer.js +44 -1
  103. package/dist/search/store.js +23 -1
  104. package/dist/session-distill.js +84 -0
  105. package/dist/session.js +92 -16
  106. package/dist/skill-install.js +53 -13
  107. package/dist/skills.js +33 -0
  108. package/dist/slash-completion.js +155 -0
  109. package/dist/support-dump.js +206 -0
  110. package/dist/tool-catalog.js +59 -0
  111. package/dist/tools/edit.js +45 -15
  112. package/dist/tools/git.js +10 -5
  113. package/dist/tools/homeassistant.js +106 -0
  114. package/dist/tools/index.js +10 -0
  115. package/dist/tools/list.js +19 -6
  116. package/dist/tools/permission.js +992 -12
  117. package/dist/tools/polyglot.js +126 -0
  118. package/dist/tools/read.js +16 -4
  119. package/dist/tools/sandbox.js +38 -13
  120. package/dist/tools/schedule.js +19 -3
  121. package/dist/tools/search.js +226 -15
  122. package/dist/tools/task.js +40 -9
  123. package/dist/tools/timeout.js +23 -3
  124. package/dist/tools/web-fetch-tool.js +33 -0
  125. package/dist/trust.js +11 -1
  126. package/dist/turn-retrieval.js +83 -0
  127. package/dist/ui/app.js +878 -32
  128. package/dist/ui/banner.js +78 -4
  129. package/dist/ui/history.js +37 -5
  130. package/dist/ui/markdown.js +122 -0
  131. package/dist/ui/mentions.js +3 -2
  132. package/dist/ui/overlay.js +496 -0
  133. package/dist/ui/queue.js +23 -0
  134. package/dist/ui/render.js +20 -1
  135. package/dist/ui/session-panel.js +115 -0
  136. package/dist/ui/setup-providers.js +40 -0
  137. package/dist/ui/setup.js +172 -46
  138. package/dist/ui/status.js +142 -0
  139. package/dist/ui/thinking-panel.js +36 -0
  140. package/dist/ui/tool-trail.js +97 -0
  141. package/dist/ui/transcript.js +26 -0
  142. package/dist/ui/useBusyElapsed.js +19 -0
  143. package/dist/ui/useEditor.js +144 -5
  144. package/dist/ui/useGitBranch.js +57 -0
  145. package/dist/update.js +56 -17
  146. package/dist/web-fetch.js +637 -0
  147. package/dist/web-surface.js +190 -0
  148. package/dist/worktree.js +175 -4
  149. package/package.json +5 -5
  150. package/second-brain/AGENTS.md +6 -4
  151. package/second-brain/CLAUDE.md +7 -1
  152. package/second-brain/Evals/_Index.md +10 -2
  153. package/second-brain/Evals/quality-ledger.md +9 -1
  154. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  155. package/second-brain/GEMINI.md +5 -4
  156. package/second-brain/Home.md +1 -1
  157. package/second-brain/Projects/_Index.md +19 -4
  158. package/second-brain/Projects/sanook-cli/_Index.md +30 -0
  159. package/second-brain/Projects/sanook-cli/context.md +35 -0
  160. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  161. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  162. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  163. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +197 -0
  164. package/second-brain/README.md +1 -1
  165. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  166. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  167. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  168. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  169. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  170. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  171. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  172. package/second-brain/Research/_Index.md +8 -1
  173. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  174. package/second-brain/Reviews/_Index.md +1 -1
  175. package/second-brain/Runbooks/_Index.md +6 -1
  176. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  177. package/second-brain/SANOOK.md +45 -0
  178. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  179. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  180. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  181. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  182. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  183. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  184. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  185. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  186. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  187. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  188. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  189. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  190. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  191. package/second-brain/Sessions/_Index.md +15 -1
  192. package/second-brain/Shared/AI-Context-Index.md +22 -0
  193. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  194. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  195. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  196. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  197. package/second-brain/Shared/Operating-State/current-state.md +14 -4
  198. package/second-brain/Shared/Scripts/_Index.md +3 -1
  199. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  200. package/second-brain/Shared/Tech-Standards/_Index.md +6 -1
  201. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  202. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  203. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  204. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  205. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  206. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  207. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  208. package/second-brain/Templates/_Index.md +9 -0
  209. package/second-brain/Templates/final-lite.md +111 -0
  210. package/second-brain/Templates/final.md +231 -0
  211. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  212. package/second-brain/Templates/project-workspace/context.md +28 -0
  213. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  214. package/second-brain/Templates/project-workspace/overview.md +39 -0
  215. package/second-brain/Templates/project-workspace/repo.md +33 -0
  216. package/second-brain/Vault Structure Map.md +2 -1
  217. package/skills/structured-output-llm/SKILL.md +1 -1
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,52 +36,115 @@ 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),
49
+ /** UI + setup wizard language */
50
+ locale: z.enum(['en', 'th']).catch('th').default('th'),
40
51
  });
41
52
  const DEFAULT_THINKING_BUDGET = 4096;
53
+ function normalizeThinkingBudget(value) {
54
+ const budget = Math.floor(value);
55
+ return Number.isSafeInteger(budget) && budget > 0 ? budget : undefined;
56
+ }
42
57
  /** parse thinking config (config field หรือ env) → budget tokens (undefined = ปิด) */
43
58
  function parseThinking(v) {
44
- if (typeof v === 'number' && v > 0)
45
- return Math.floor(v);
59
+ if (typeof v === 'number' && Number.isFinite(v))
60
+ return normalizeThinkingBudget(v);
46
61
  if (v === true)
47
62
  return DEFAULT_THINKING_BUDGET;
48
63
  if (typeof v === 'string') {
49
- if (/^\d+$/.test(v))
50
- return Number.parseInt(v, 10);
51
- if (['on', 'true', '1', 'yes'].includes(v.toLowerCase()))
64
+ const clean = v.trim();
65
+ if (/^\d+$/.test(clean))
66
+ return normalizeThinkingBudget(Number(clean));
67
+ if (['on', 'true', '1', 'yes'].includes(clean.toLowerCase()))
52
68
  return DEFAULT_THINKING_BUDGET;
53
69
  }
54
70
  return undefined;
55
71
  }
72
+ function trimmedString(v) {
73
+ if (typeof v !== 'string')
74
+ return undefined;
75
+ const clean = v.trim();
76
+ return clean ? clean : undefined;
77
+ }
78
+ function parseCacheTtl(v) {
79
+ const clean = trimmedString(v);
80
+ return clean === '5m' || clean === '1h' ? clean : undefined;
81
+ }
82
+ function parseCompaction(v) {
83
+ const clean = trimmedString(v);
84
+ return clean === 'truncate' || clean === 'summarize' ? clean : undefined;
85
+ }
86
+ function parseContextCompression(v) {
87
+ const clean = trimmedString(v);
88
+ return clean === 'off' || clean === 'selective' || clean === 'headroom' ? clean : undefined;
89
+ }
56
90
  /**
57
91
  * อ่าน tuning knobs (cache TTL / thinking / compaction / summary model) จาก global config.json
58
- * + env override (SANOOK_CACHE_TTL / SANOOK_THINKING / SANOOK_COMPACTION / SANOOK_SUMMARY_MODEL).
92
+ * + env override (SANOOK_CACHE_TTL / SANOOK_THINKING / SANOOK_COMPACTION / SANOOK_CONTEXT_COMPRESSION / SANOOK_SUMMARY_MODEL).
59
93
  * อ่านตรงจาก config.json (เลี่ยง thread ผ่าน call stack ลึก) — เบา, เรียกครั้งเดียวต่อ turn.
60
94
  */
61
95
  export async function agentTuning() {
62
96
  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 };
97
+ const cacheTtl = parseCacheTtl(process.env.SANOOK_CACHE_TTL) ?? parseCacheTtl(raw.cacheTtl) ?? '5m';
98
+ const thinkingBudget = parseThinking(trimmedString(process.env.SANOOK_THINKING) ?? raw.thinking);
99
+ const compaction = parseCompaction(process.env.SANOOK_COMPACTION) ?? parseCompaction(raw.compaction) ?? 'truncate';
100
+ const contextCompression = parseContextCompression(process.env.SANOOK_CONTEXT_COMPRESSION) ?? parseContextCompression(raw.contextCompression) ?? 'selective';
101
+ const summaryModel = trimmedString(process.env.SANOOK_SUMMARY_MODEL) ?? trimmedString(raw.summaryModel);
102
+ return { cacheTtl, thinkingBudget, compaction, contextCompression, summaryModel };
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({});
69
126
  }
70
127
  async function readJson(path) {
71
128
  try {
72
129
  const parsed = JSON.parse(await readFile(path, 'utf8'));
73
- return parsed && typeof parsed === 'object' ? parsed : {};
130
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
74
131
  }
75
132
  catch {
76
133
  return {}; // ไม่มีไฟล์ / parse ไม่ได้ = ใช้ default
77
134
  }
78
135
  }
136
+ // key ที่ untrusted project ตั้งไม่ได้ (ต้อง `sanook trust` ก่อน):
137
+ // - permissionMode: auto = auto-approve mutation (รัน bash/แก้ไฟล์ไม่ถาม) — อันตรายสุด
138
+ // - budgetUsd: repo อันตรายตั้งสูงๆ = ปิด spend cap ของ user (เปลืองเงินจริง)
139
+ // - pricing: ตั้งราคาปลอม = ทำให้ budget cap ไม่ trigger (ซ่อน cost / bypass cap)
140
+ // (model/maxSteps/embeddingModel ฯลฯ ปล่อยได้ — เป็น preference ที่ user เห็น/override ได้ และตอนนี้ถูกคุมด้วย budget จริงของ user)
141
+ const UNTRUSTED_PROJECT_DENY = new Set(['permissionMode', 'budgetUsd', 'pricing']);
79
142
  function sanitizeUntrustedProjectConfig(cfg) {
80
- const out = { ...cfg };
81
- delete out.permissionMode;
143
+ const out = {};
144
+ for (const [k, v] of Object.entries(cfg)) {
145
+ if (!UNTRUSTED_PROJECT_DENY.has(k))
146
+ out[k] = v;
147
+ }
82
148
  return out;
83
149
  }
84
150
  /**
@@ -93,15 +159,16 @@ export async function loadConfig(overrides = {}, cwd = process.cwd()) {
93
159
  const trust = await projectTrustStatus(root);
94
160
  const project = trust.trusted ? projectRaw : sanitizeUntrustedProjectConfig(projectRaw);
95
161
  const envConfig = {};
96
- if (process.env[BRAND.modelEnvVar])
97
- envConfig.model = process.env[BRAND.modelEnvVar];
162
+ const envModel = trimmedString(process.env[BRAND.modelEnvVar]);
163
+ if (envModel)
164
+ envConfig.model = envModel;
98
165
  const cleanOverrides = {};
99
166
  for (const [k, v] of Object.entries(overrides)) {
100
167
  if (v !== undefined)
101
168
  cleanOverrides[k] = v;
102
169
  }
103
170
  const merged = { ...global, ...project, ...envConfig, ...cleanOverrides };
104
- const config = ConfigSchema.parse(merged);
171
+ const config = parseConfigGraceful(merged);
105
172
  // pricing override: config.pricing + env SANOOK_PRICING (JSON) → ลงทะเบียนเข้า cost table
106
173
  registerPricing(config.pricing);
107
174
  registerPricing(parseEnvPricing());
@@ -113,16 +180,29 @@ function parseEnvPricing() {
113
180
  if (!raw)
114
181
  return undefined;
115
182
  try {
116
- const parsed = JSON.parse(raw);
117
- const res = PricingOverrideSchema.safeParse(parsed);
118
- return res.success ? res.data : undefined;
183
+ return parsePricingOverride(raw);
119
184
  }
120
185
  catch {
121
186
  return undefined; // JSON ไม่ถูก = ข้าม (ไม่ทำให้ boot ล้ม)
122
187
  }
123
188
  }
124
189
  export function parsePricingOverride(raw) {
125
- return PricingOverrideSchema.parse(JSON.parse(raw));
190
+ let parsed;
191
+ try {
192
+ parsed = JSON.parse(raw);
193
+ }
194
+ catch {
195
+ throw new Error('pricing JSON parse ไม่สำเร็จ');
196
+ }
197
+ const res = PricingOverrideSchema.safeParse(parsed);
198
+ if (!res.success) {
199
+ const details = res.error.issues
200
+ .slice(0, 3)
201
+ .map((issue) => `${issue.path.length ? issue.path.join('.') : 'pricing'}: ${issue.message}`)
202
+ .join('; ');
203
+ throw new Error(`pricing schema ไม่ถูกต้อง${details ? ` — ${details}` : ''}`);
204
+ }
205
+ return res.data;
126
206
  }
127
207
  /** ครั้งแรกที่รัน (ยังไม่มี global config) → ต้องทำ setup wizard */
128
208
  export async function isFirstRun() {
@@ -134,7 +214,7 @@ export async function isFirstRun() {
134
214
  return true;
135
215
  }
136
216
  }
137
- /** บันทึก global config (model/provider ที่เลือกตอน setup) */
217
+ /** บันทึก global config (model/provider/locale ที่เลือกตอน setup) */
138
218
  export async function saveGlobalConfig(cfg) {
139
219
  await mkdir(CONFIG_DIR, { recursive: true });
140
220
  const existing = await readJson(CONFIG_PATH);
@@ -152,6 +232,23 @@ export async function saveBrainPath(path) {
152
232
  export async function readGlobalConfigRaw() {
153
233
  return readJson(CONFIG_PATH);
154
234
  }
235
+ /** path ของ auth.json (ใช้โชว์ใน CLI เท่านั้น; ห้าม print raw secret) */
236
+ export function authConfigPath() {
237
+ return AUTH_PATH;
238
+ }
239
+ function isSafeAuthEnvVarName(name) {
240
+ return AUTH_ENV_VAR_RE.test(name) && !RESERVED_AUTH_KEYS.has(name);
241
+ }
242
+ /** อ่าน auth.json ดิบแบบกรองเฉพาะ string values — caller ต้อง redact ก่อนโชว์ */
243
+ export async function readStoredAuthRaw() {
244
+ const raw = await readJson(AUTH_PATH);
245
+ const auth = {};
246
+ for (const [k, v] of Object.entries(raw)) {
247
+ if (isSafeAuthEnvVarName(k) && typeof v === 'string')
248
+ auth[k] = v;
249
+ }
250
+ return auth;
251
+ }
155
252
  /** merge patch ลง config.json (สำหรับ `sanook config set`) */
156
253
  export async function patchGlobalConfig(patch) {
157
254
  await mkdir(CONFIG_DIR, { recursive: true });
@@ -161,25 +258,44 @@ export async function patchGlobalConfig(patch) {
161
258
  }
162
259
  /** บันทึก API key ลง ~/.sanook/auth.json (chmod 0600) + set env ทันทีสำหรับ session นี้ */
163
260
  export async function saveKey(envVar, key) {
261
+ if (!isSafeAuthEnvVarName(envVar))
262
+ throw new Error(`env var ไม่ถูกต้อง: ${envVar}`);
164
263
  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
- }
264
+ const auth = await readStoredAuthRaw();
172
265
  auth[envVar] = key;
173
266
  await writeFile(AUTH_PATH, `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
174
267
  await chmod(AUTH_PATH, 0o600); // เจ้าของอ่าน/เขียนเท่านั้น
175
268
  process.env[envVar] = key;
176
269
  }
270
+ /** ลบ key ที่ Sanook เก็บไว้ใน auth.json (ไม่แตะ env จริงของ shell ภายนอก) */
271
+ export async function removeStoredKey(envVar) {
272
+ if (!isSafeAuthEnvVarName(envVar))
273
+ return false;
274
+ await mkdir(CONFIG_DIR, { recursive: true });
275
+ const auth = await readStoredAuthRaw();
276
+ if (!Object.prototype.hasOwnProperty.call(auth, envVar))
277
+ return false;
278
+ delete auth[envVar];
279
+ await writeFile(AUTH_PATH, `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
280
+ await chmod(AUTH_PATH, 0o600).catch(() => { });
281
+ delete process.env[envVar];
282
+ return true;
283
+ }
284
+ /** ล้าง auth.json ที่ Sanook เก็บไว้ทั้งหมด */
285
+ export async function clearStoredAuth() {
286
+ await mkdir(CONFIG_DIR, { recursive: true });
287
+ const auth = await readStoredAuthRaw();
288
+ for (const envVar of Object.keys(auth))
289
+ delete process.env[envVar];
290
+ await writeFile(AUTH_PATH, '{}\n', { mode: 0o600 });
291
+ await chmod(AUTH_PATH, 0o600).catch(() => { });
292
+ }
177
293
  /** โหลด key จาก auth.json เข้า env ตอน boot (ไม่ override env ที่ตั้งไว้แล้ว) */
178
294
  export async function loadKeysIntoEnv() {
179
295
  try {
180
- const auth = JSON.parse(await readFile(AUTH_PATH, 'utf8'));
296
+ const auth = await readStoredAuthRaw();
181
297
  for (const [k, v] of Object.entries(auth)) {
182
- if (!process.env[k] && typeof v === 'string')
298
+ if (!process.env[k])
183
299
  process.env[k] = v;
184
300
  }
185
301
  }
@@ -0,0 +1,191 @@
1
+ const DEFAULT_TARGET_CHARS = 6_000;
2
+ const DEFAULT_MIN_CHARS = 8_000;
3
+ const DEFAULT_MAX_LINE_CHARS = 800;
4
+ const IMPORTANT_RE = /\b(error|exception|fail(?:ed|ure)?|warning|warn|timeout|denied|unauthorized|traceback|panic|regression|todo|fixme)\b/i;
5
+ const CODE_RE = /^\s*(?:import|export|function|class|interface|type|const|let|var|async|await|return|if|for|while|switch|case)\b/;
6
+ const DIFF_RE = /^\s*(?:diff --git|@@|\+\+\+|---|\+|-)/;
7
+ const PATH_RE = /(?:^|\s)[\w@./-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|py|rs|go|java|css|scss|html|yml|yaml|toml)(?::\d+)?\b/;
8
+ const STRUCTURE_RE = /^\s*(?:#{1,6}\s|\*|-|\d+\.|["'][^"']+["']\s*:)/;
9
+ const TOKEN_RE = /[\p{L}\p{N}_./:-]{2,}/gu;
10
+ function clampPositive(value, fallback) {
11
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
12
+ }
13
+ function tokens(line) {
14
+ return [...line.toLowerCase().matchAll(TOKEN_RE)].map((match) => match[0]);
15
+ }
16
+ function queryTokens(query) {
17
+ const out = new Set();
18
+ for (const token of tokens(query ?? '')) {
19
+ if (token.length >= 3)
20
+ out.add(token);
21
+ const leaf = token.split(/[/:\\]/).pop();
22
+ if (leaf && leaf.length >= 3)
23
+ out.add(leaf);
24
+ }
25
+ return out;
26
+ }
27
+ function shrinkLine(line, maxChars) {
28
+ if (line.length <= maxChars)
29
+ return line;
30
+ const head = Math.max(80, Math.floor(maxChars * 0.55));
31
+ const tail = Math.max(60, maxChars - head - 48);
32
+ return `${line.slice(0, head)} ... [line pruned ${line.length - head - tail} chars] ... ${line.slice(-tail)}`;
33
+ }
34
+ function addAnchors(lines, budget, fromEnd = false) {
35
+ const selected = new Set();
36
+ let chars = 0;
37
+ for (let step = 0; step < lines.length; step++) {
38
+ const index = fromEnd ? lines.length - 1 - step : step;
39
+ if (index < 0 || index >= lines.length)
40
+ break;
41
+ if (chars >= budget)
42
+ break;
43
+ selected.add(index);
44
+ chars += lines[index].length + 1;
45
+ }
46
+ return selected;
47
+ }
48
+ function selectedChars(lines, selected, maxLineChars) {
49
+ let chars = 0;
50
+ for (const index of selected)
51
+ chars += Math.min(lines[index].length, maxLineChars) + 1;
52
+ return chars;
53
+ }
54
+ function lineScores(lines, selected, query) {
55
+ const candidates = lines
56
+ .map((line, index) => ({ line, index }))
57
+ .filter(({ line, index }) => !selected.has(index) && line.trim());
58
+ const df = new Map();
59
+ for (const { line } of candidates) {
60
+ for (const token of new Set(tokens(line)))
61
+ df.set(token, (df.get(token) ?? 0) + 1);
62
+ }
63
+ const total = Math.max(1, candidates.length);
64
+ const seenLines = new Map();
65
+ const scores = new Map();
66
+ for (const { line, index } of candidates) {
67
+ const clean = line.trim();
68
+ const lineTokens = tokens(clean);
69
+ let score = 0;
70
+ for (const token of lineTokens)
71
+ score += Math.log((total + 1) / ((df.get(token) ?? 0) + 1));
72
+ score = score / Math.sqrt(Math.max(1, lineTokens.length));
73
+ if (query.size) {
74
+ let overlap = 0;
75
+ for (const token of new Set(lineTokens)) {
76
+ const leaf = token.split(/[/:\\]/).pop() ?? token;
77
+ if (query.has(token) || query.has(leaf))
78
+ overlap += 1;
79
+ }
80
+ if (overlap)
81
+ score += Math.min(40, overlap * 18);
82
+ }
83
+ if (IMPORTANT_RE.test(clean))
84
+ score += 8;
85
+ if (PATH_RE.test(clean))
86
+ score += 5;
87
+ if (DIFF_RE.test(clean))
88
+ score += 4;
89
+ if (CODE_RE.test(clean))
90
+ score += 3;
91
+ if (STRUCTURE_RE.test(clean))
92
+ score += 2;
93
+ if (/https?:\/\//i.test(clean))
94
+ score += 2;
95
+ if (/^\s*[}\])],?\s*$/.test(clean))
96
+ score -= 1;
97
+ if (clean.length > 500 && !/\s/.test(clean))
98
+ score -= 5;
99
+ const repeated = seenLines.get(clean) ?? 0;
100
+ if (repeated)
101
+ score -= Math.min(8, repeated * 2);
102
+ seenLines.set(clean, repeated + 1);
103
+ scores.set(index, score);
104
+ }
105
+ return scores;
106
+ }
107
+ function renderSelected(lines, selected, maxLineChars) {
108
+ const out = [];
109
+ let omittedLines = 0;
110
+ let omittedChars = 0;
111
+ let gapLines = 0;
112
+ let gapChars = 0;
113
+ const flushGap = () => {
114
+ if (!gapLines)
115
+ return;
116
+ out.push(`... [selective context compression: omitted ${gapLines} line(s), ${gapChars} chars] ...`);
117
+ omittedLines += gapLines;
118
+ omittedChars += gapChars;
119
+ gapLines = 0;
120
+ gapChars = 0;
121
+ };
122
+ for (let index = 0; index < lines.length; index++) {
123
+ if (selected.has(index)) {
124
+ flushGap();
125
+ out.push(shrinkLine(lines[index], maxLineChars));
126
+ }
127
+ else {
128
+ gapLines += 1;
129
+ gapChars += lines[index].length + 1;
130
+ }
131
+ }
132
+ flushGap();
133
+ return { text: out.join('\n'), omittedLines, omittedChars };
134
+ }
135
+ /**
136
+ * Zero-LLM selective context compression inspired by Selective Context / Headroom:
137
+ * keep anchors plus high-information lines (errors, paths, code structure, rare terms),
138
+ * then preserve original order with omission markers.
139
+ */
140
+ export function selectiveCompressText(input, options = {}) {
141
+ const originalChars = input.length;
142
+ const targetChars = clampPositive(options.targetChars, DEFAULT_TARGET_CHARS);
143
+ const minChars = clampPositive(options.minChars, DEFAULT_MIN_CHARS);
144
+ const maxLineChars = clampPositive(options.maxLineChars, DEFAULT_MAX_LINE_CHARS);
145
+ if (originalChars <= minChars || originalChars <= targetChars) {
146
+ return { text: input, changed: false, originalChars, compressedChars: originalChars, omittedLines: 0, omittedChars: 0 };
147
+ }
148
+ const lines = input.split(/\r?\n/);
149
+ if (lines.length <= 4) {
150
+ const text = shrinkLine(input, targetChars);
151
+ return {
152
+ text,
153
+ changed: text !== input,
154
+ originalChars,
155
+ compressedChars: text.length,
156
+ omittedLines: text === input ? 0 : 1,
157
+ omittedChars: Math.max(0, originalChars - text.length),
158
+ };
159
+ }
160
+ const headBudget = Math.min(clampPositive(options.headChars, Math.floor(targetChars * 0.18)), Math.floor(targetChars * 0.35));
161
+ const tailBudget = Math.min(clampPositive(options.tailChars, Math.floor(targetChars * 0.25)), Math.floor(targetChars * 0.45));
162
+ const selected = new Set([
163
+ ...addAnchors(lines, headBudget),
164
+ ...addAnchors(lines, tailBudget, true),
165
+ ]);
166
+ const scores = lineScores(lines, selected, queryTokens(options.query));
167
+ const ranked = [...scores.entries()].sort((a, b) => b[1] - a[1]);
168
+ let used = selectedChars(lines, selected, maxLineChars);
169
+ const softBudget = Math.max(400, targetChars - 600);
170
+ for (const [index] of ranked) {
171
+ const nextCost = Math.min(lines[index].length, maxLineChars) + 1;
172
+ if (used + nextCost > softBudget && selected.size > 0)
173
+ continue;
174
+ selected.add(index);
175
+ used += nextCost;
176
+ if (used >= softBudget)
177
+ break;
178
+ }
179
+ const rendered = renderSelected(lines, selected, maxLineChars);
180
+ if (rendered.text.length >= originalChars) {
181
+ return { text: input, changed: false, originalChars, compressedChars: originalChars, omittedLines: 0, omittedChars: 0 };
182
+ }
183
+ return {
184
+ text: rendered.text,
185
+ changed: true,
186
+ originalChars,
187
+ compressedChars: rendered.text.length,
188
+ omittedLines: rendered.omittedLines,
189
+ omittedChars: rendered.omittedChars,
190
+ };
191
+ }
@@ -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
+ }