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
@@ -1,7 +1,6 @@
1
1
  import { createAnthropic } from '@ai-sdk/anthropic';
2
2
  import { createOpenAI } from '@ai-sdk/openai';
3
3
  import { createGoogleGenerativeAI } from '@ai-sdk/google';
4
- import { createDeepSeek } from '@ai-sdk/deepseek';
5
4
  import { createXai } from '@ai-sdk/xai';
6
5
  import { createMistral } from '@ai-sdk/mistral';
7
6
  import { createGroq } from '@ai-sdk/groq';
@@ -71,16 +70,6 @@ export const PROVIDERS = {
71
70
  create: (key, baseURL) => createOpenAI({ apiKey: key, baseURL }),
72
71
  note: 'Bearer key. org/project ผ่าน env. ห้าม reuse ChatGPT/Codex OAuth',
73
72
  },
74
- deepseek: {
75
- id: 'deepseek',
76
- label: 'DeepSeek',
77
- envVar: 'DEEPSEEK_API_KEY',
78
- requiresKey: true,
79
- keyFormat: null, // opaque sk- → ข้าม format check
80
- // V4 ids (doc audit มิ.ย. 2026): deepseek-chat/deepseek-reasoner เลิกใช้ 2026-07-24 → redirect มา V4 (dual thinking-mode)
81
- models: { default: 'deepseek-v4-flash', smart: 'deepseek-v4-pro', fast: 'deepseek-v4-flash' },
82
- create: (key) => createDeepSeek({ apiKey: key }),
83
- },
84
73
  xai: {
85
74
  id: 'xai',
86
75
  label: 'xAI Grok',
@@ -120,7 +109,7 @@ export const PROVIDERS = {
120
109
  requiresKey: false,
121
110
  localPlaceholderKey: 'ollama',
122
111
  keyFormat: null,
123
- models: { default: 'qwen3', llama: 'llama3.3' },
112
+ models: { default: 'llama3.3', llama: 'llama3.3', mistral: 'mistral' },
124
113
  create: (key, baseURL) => createOpenAICompatible({ name: 'ollama', apiKey: key, baseURL: baseURL ?? 'http://localhost:11434/v1' }),
125
114
  note: 'OpenAI-compat /v1 endpoint. ไม่ต้อง key',
126
115
  },
@@ -136,32 +125,6 @@ export const PROVIDERS = {
136
125
  create: (key, baseURL) => createOpenAICompatible({ name: 'lmstudio', apiKey: key, baseURL: baseURL ?? 'http://localhost:1234/v1' }),
137
126
  note: 'ต้อง Start Server ในแอปก่อน; โหลด model เดียว ใส่ id อะไรก็ serve ตัวนั้น',
138
127
  },
139
- // ── Cloud BYOK (OpenAI-compatible, จีน — data residency, ไม่มี OAuth landmine) ──
140
- minimax: {
141
- id: 'minimax',
142
- label: 'MiniMax',
143
- envVar: 'MINIMAX_API_KEY',
144
- baseURL: 'https://api.minimax.io/v1',
145
- requiresKey: true,
146
- keyFormat: null, // opaque
147
- models: { default: 'MiniMax-M2.7', smart: 'MiniMax-M3', fast: 'MiniMax-M2.7' },
148
- create: (key, baseURL) => createOpenAICompatible({ name: 'minimax', apiKey: key, baseURL: baseURL ?? 'https://api.minimax.io/v1' }),
149
- note: 'OpenAI-compat /v1. data จีน. MINIMAX_BASE_URL override (intl ↔ api.minimaxi.com/v1)',
150
- },
151
- glm: {
152
- id: 'glm',
153
- label: 'GLM (z.ai / Zhipu Coding Plan)',
154
- envVar: 'ZHIPU_API_KEY',
155
- envFallbacks: ['ZAI_API_KEY', 'GLM_API_KEY'],
156
- // Coding Plan (subscription) ใช้ Anthropic Messages API — เหมือนที่ต่อกับ Claude Code.
157
- // pay-as-you-go /paas/v4 (OpenAI-compat) มีแค่ glm-4.5-flash ฟรี ที่เหลือ 429 ถ้าไม่มี balance
158
- baseURL: 'https://api.z.ai/api/anthropic/v1',
159
- requiresKey: true,
160
- keyFormat: null, // opaque ({id}.{secret})
161
- models: { default: 'glm-4.6', smart: 'glm-5.1', air: 'glm-4.5-air', glm: 'glm-4.6' },
162
- create: (key, baseURL) => createAnthropic({ apiKey: key, baseURL: baseURL ?? 'https://api.z.ai/api/anthropic/v1' }),
163
- note: 'z.ai Coding Plan ผ่าน Anthropic Messages API. GLM_BASE_URL override → open.bigmodel.cn/api/anthropic/v1 (จีน)',
164
- },
165
128
  // ── Delegate: OpenAI Codex ผ่าน ChatGPT plan quota (wrap official codex CLI, ToS-safe) ──
166
129
  codex: {
167
130
  id: 'codex',
@@ -191,13 +154,10 @@ const GLOBAL_ALIAS = {
191
154
  gemini: { provider: 'google', alias: 'gemini' },
192
155
  flash: { provider: 'google', alias: 'flash' },
193
156
  grok: { provider: 'xai', alias: 'grok' },
194
- deepseek: { provider: 'deepseek', alias: 'default' },
195
157
  mistral: { provider: 'mistral', alias: 'default' },
196
158
  groq: { provider: 'groq', alias: 'default' },
197
159
  ollama: { provider: 'ollama', alias: 'default' },
198
160
  lmstudio: { provider: 'lmstudio', alias: 'default' },
199
- glm: { provider: 'glm', alias: 'default' },
200
- minimax: { provider: 'minimax', alias: 'default' },
201
161
  };
202
162
  /** parse "provider:model" | "provider:alias" | alias | "model" (default anthropic) */
203
163
  export function parseSpec(spec) {
@@ -221,17 +181,18 @@ export function specKey(spec) {
221
181
  const { provider, model } = parseSpec(spec);
222
182
  return `${provider}:${model}`;
223
183
  }
184
+ /** canonical display/state spec: aliases become "provider:model-id" before reaching the REPL state. */
185
+ export function canonicalSpec(spec) {
186
+ return specKey(spec.trim());
187
+ }
224
188
  /** หน้า console ที่ใช้สร้าง API key ต่อ provider — โชว์ในข้อความ error/wizard ("ไปเอา key ที่ไหน") */
225
189
  const CONSOLE_URLS = {
226
190
  anthropic: 'https://console.anthropic.com/settings/keys',
227
191
  google: 'https://aistudio.google.com/apikey',
228
192
  openai: 'https://platform.openai.com/api-keys',
229
- deepseek: 'https://platform.deepseek.com/api_keys',
230
193
  xai: 'https://console.x.ai',
231
194
  mistral: 'https://console.mistral.ai/api-keys',
232
195
  groq: 'https://console.groq.com/keys',
233
- minimax: 'https://platform.minimax.io',
234
- glm: 'https://z.ai/manage-apikey/apikey-list',
235
196
  };
236
197
  export function consoleUrl(provider) {
237
198
  return CONSOLE_URLS[provider];
@@ -245,6 +206,8 @@ export function hasUsableEnvKey(provider) {
245
206
  const cfg = PROVIDERS[provider];
246
207
  if (!cfg)
247
208
  return false;
209
+ if (cfg.kind === 'delegate')
210
+ return false; // ต้องเช็ก readiness แยก (เช่น codex CLI installed + logged in)
248
211
  if (!cfg.requiresKey)
249
212
  return true; // local — ไม่ต้อง key
250
213
  const k = resolveKeyFromEnv(cfg.envVar, cfg.envFallbacks);
@@ -260,7 +223,7 @@ export function hasUsableEnvKey(provider) {
260
223
  }
261
224
  /** หา provider ที่ "มี key ใช้ได้จริงใน env" (cloud, ตามลำดับนิยม) — ใช้ทำ first-run smart skip + แนะ headless */
262
225
  export function detectEnvProvider() {
263
- for (const id of ['anthropic', 'openai', 'google', 'deepseek', 'xai', 'mistral', 'groq', 'glm', 'minimax']) {
226
+ for (const id of ['anthropic', 'openai', 'google', 'xai', 'mistral', 'groq']) {
264
227
  const cfg = PROVIDERS[id];
265
228
  if (cfg?.requiresKey && hasUsableEnvKey(id)) {
266
229
  return { provider: id, label: cfg.label, envVar: cfg.envVar, model: cfg.models.default };
@@ -292,10 +255,14 @@ export function resolveModel(spec) {
292
255
  const found = resolveKeyFromEnv(cfg.envVar, cfg.envFallbacks);
293
256
  if (!found) {
294
257
  const url = consoleUrl(provider);
258
+ const codexHint = provider === 'openai'
259
+ ? `\n • ถ้าต้องการใช้ ChatGPT plan ไม่ใช้ API key: เลือก \`/model codex\` แล้วรัน \`codex login\``
260
+ : '';
295
261
  throw new Error(`ยังไม่มี API key ของ ${cfg.label} (${cfg.envVar})\n` +
296
262
  (url ? ` • เอา key ที่: ${url}\n` : '') +
297
263
  ` • ตั้ง: export ${cfg.envVar}="..." ` +
298
- `หรือรัน \`${BRAND.cliName}\` (ไม่ใส่ task) เพื่อ setup wizard`);
264
+ `หรือรัน \`${BRAND.cliName}\` (ไม่ใส่ task) เพื่อ setup wizard` +
265
+ codexHint);
299
266
  }
300
267
  assertDirectApiKey(cfg, found); // reject OAuth/subscription token + format ผิด
301
268
  key = found;
@@ -303,7 +270,7 @@ export function resolveModel(spec) {
303
270
  else {
304
271
  key = resolveKeyFromEnv(cfg.envVar) ?? cfg.localPlaceholderKey ?? 'local';
305
272
  }
306
- // <PROVIDER>_BASE_URL env → override (สลับ region intl/จีน); ไม่งั้น local อ่าน env, cloud ใช้ default
273
+ // <PROVIDER>_BASE_URL env → override (สลับ region); ไม่งั้น local อ่าน env, cloud ใช้ default
307
274
  const baseURL = process.env[`${cfg.id.toUpperCase()}_BASE_URL`] ??
308
275
  (cfg.requiresKey ? cfg.baseURL : process.env[cfg.envVar] ?? cfg.baseURL);
309
276
  return cfg.create(key, baseURL)(model);
@@ -12,15 +12,11 @@
12
12
  // or a stray [[ inside a code fence degrade to "no frontmatter / no links"
13
13
  // rather than throwing. We must never block indexing a real, messy vault file.
14
14
  // ============================================================================
15
+ import { createHash } from 'node:crypto';
15
16
  const MIN_CHARS = 120; // sections shorter than this fold into the next chunk
16
- /** deterministic short hash of a path (fnv-1a base36) no crypto dep, stable chunk ids. */
17
+ /** deterministic path hash SHA-256 prefix keeps chunk ids short without 32-bit collision risk. */
17
18
  export function pathHash(path) {
18
- let h = 0x811c9dc5;
19
- for (let i = 0; i < path.length; i++) {
20
- h ^= path.charCodeAt(i);
21
- h = Math.imul(h, 0x01000193);
22
- }
23
- return (h >>> 0).toString(36);
19
+ return createHash('sha256').update(path).digest('hex').slice(0, 16);
24
20
  }
25
21
  /** split a leading `---\n…\n---` frontmatter block from the body. Defensive: no block ⇒ {} + full md. */
26
22
  export function parseFrontmatter(md) {
@@ -31,7 +27,10 @@ export function parseFrontmatter(md) {
31
27
  if (end === -1)
32
28
  return { data: empty, body: md };
33
29
  const block = md.slice(3, end).trim();
34
- const body = md.slice(md.indexOf('\n', end + 1) + 1).replace(/^\n+/, '');
30
+ // หา newline หลัง closing fence; ถ้าไม่มี (frontmatter-only ไม่มี trailing newline) body = '' ไม่ใช่ทั้งไฟล์
31
+ // (indexOf คืน -1 → slice(0) = ทั้งไฟล์ → frontmatter รั่วเข้า body ทำ index/search เพี้ยน)
32
+ const afterFence = md.indexOf('\n', end + 1);
33
+ const body = (afterFence === -1 ? '' : md.slice(afterFence + 1)).replace(/^\n+/, '');
35
34
  const data = { tags: [] };
36
35
  const lines = block.split('\n');
37
36
  for (let i = 0; i < lines.length; i++) {
@@ -0,0 +1,83 @@
1
+ import { inlineValue, takeValue } from '../cli-option-values.js';
2
+ import { SEARCH_SOURCES } from './index-core.js';
3
+ const SEARCH_MODES = ['auto', 'fts', 'semantic', 'hybrid'];
4
+ function isSearchMode(v) {
5
+ return SEARCH_MODES.includes(v);
6
+ }
7
+ function isSearchSource(v) {
8
+ return SEARCH_SOURCES.includes(v);
9
+ }
10
+ function parsePositiveInteger(raw) {
11
+ if (!raw || !/^[1-9]\d*$/.test(raw))
12
+ return undefined;
13
+ const n = Number(raw);
14
+ return Number.isSafeInteger(n) ? n : undefined;
15
+ }
16
+ function inlineSourceValue(value) {
17
+ return inlineValue('--source', value) ?? inlineValue('--sources', value);
18
+ }
19
+ export function parseSearchArgs(args) {
20
+ const queryParts = [];
21
+ let mode = 'auto';
22
+ let modeSet = false;
23
+ let limit = 8;
24
+ let limitSet = false;
25
+ let sources;
26
+ for (let i = 0; i < args.length; i++) {
27
+ const a = args[i];
28
+ if (a === '--') {
29
+ queryParts.push(...args.slice(i + 1));
30
+ break;
31
+ }
32
+ else if (a === '--mode' || a.startsWith('--mode=')) {
33
+ const next = a === '--mode' ? takeValue(args, i) : undefined;
34
+ const v = next ? next.value : inlineValue('--mode', a);
35
+ if (next)
36
+ i = next.nextIndex;
37
+ if (!v)
38
+ return { ok: false, message: `--mode ต้องระบุค่าเป็น ${SEARCH_MODES.join('|')}` };
39
+ if (!isSearchMode(v))
40
+ return { ok: false, message: `--mode ต้องเป็น ${SEARCH_MODES.join('|')}` };
41
+ if (modeSet)
42
+ return { ok: false, message: 'ใช้ --mode เพียงครั้งเดียว' };
43
+ mode = v;
44
+ modeSet = true;
45
+ }
46
+ else if (a === '--limit' || a.startsWith('--limit=')) {
47
+ const next = a === '--limit' ? takeValue(args, i) : undefined;
48
+ const raw = next ? next.value : inlineValue('--limit', a);
49
+ if (next)
50
+ i = next.nextIndex;
51
+ if (!raw)
52
+ return { ok: false, message: '--limit ต้องระบุค่าเป็น integer บวก เช่น 8' };
53
+ const n = parsePositiveInteger(raw);
54
+ if (n === undefined)
55
+ return { ok: false, message: '--limit ต้องเป็น integer บวก เช่น 8' };
56
+ if (limitSet)
57
+ return { ok: false, message: 'ใช้ --limit เพียงครั้งเดียว' };
58
+ limit = n;
59
+ limitSet = true;
60
+ }
61
+ else if (a === '--source' || a === '--sources' || a.startsWith('--source=') || a.startsWith('--sources=')) {
62
+ const next = a === '--source' || a === '--sources' ? takeValue(args, i) : undefined;
63
+ const raw = next ? next.value : inlineSourceValue(a);
64
+ if (next)
65
+ i = next.nextIndex;
66
+ const requested = (raw ?? '').split(',').map((s) => s.trim()).filter(Boolean);
67
+ const bad = requested.filter((s) => !isSearchSource(s));
68
+ if (!requested.length) {
69
+ return { ok: false, message: `--source ต้องระบุค่าเป็น ${SEARCH_SOURCES.join(',')} (คั่นหลายค่าได้ด้วย comma)` };
70
+ }
71
+ if (bad.length)
72
+ return { ok: false, message: `--source ต้องเป็น ${SEARCH_SOURCES.join(',')} (คั่นหลายค่าได้ด้วย comma)` };
73
+ sources = [...new Set([...(sources ?? []), ...requested])];
74
+ }
75
+ else {
76
+ queryParts.push(a);
77
+ }
78
+ }
79
+ const query = queryParts.join(' ').trim();
80
+ if (!query)
81
+ return { ok: false, message: 'ต้องใส่ query สำหรับค้นหา' };
82
+ return { ok: true, value: { query, mode, limit, sources } };
83
+ }
@@ -139,6 +139,9 @@ export async function saveVectors(vi) {
139
139
  throw e;
140
140
  }
141
141
  }
142
+ export function invalidateVectors(tag = '') {
143
+ return saveVectors(emptyVectors(tag));
144
+ }
142
145
  export async function vectorsMtimeMs() {
143
146
  try {
144
147
  return (await stat(VECTORS_PATH)).mtimeMs;
@@ -0,0 +1,22 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { appHomePath } from '../brand.js';
3
+ const EMBEDDING_MODEL_ENV = 'SANOOK_EMBEDDING_MODEL';
4
+ export function cleanEmbeddingModelSpec(v) {
5
+ if (typeof v !== 'string')
6
+ return undefined;
7
+ const clean = v.trim();
8
+ return clean ? clean : undefined;
9
+ }
10
+ /** read an optional embeddingModel spec from ~/.sanook/config.json. */
11
+ export async function configEmbeddingModel() {
12
+ try {
13
+ const cfg = JSON.parse(await readFile(appHomePath('config.json'), 'utf8'));
14
+ return cleanEmbeddingModelSpec(cfg.embeddingModel);
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ export async function embeddingModelSpec(override) {
21
+ return cleanEmbeddingModelSpec(override) ?? cleanEmbeddingModelSpec(process.env[EMBEDDING_MODEL_ENV]) ?? (await configEmbeddingModel());
22
+ }
@@ -14,11 +14,10 @@
14
14
  // lazily, and on ANY embedding error degrades to BM25 with a `degraded` flag —
15
15
  // search must never throw at the floor.
16
16
  // ============================================================================
17
- import { readFile } from 'node:fs/promises';
18
- import { appHomePath } from '../brand.js';
19
17
  import { bm25Search, termList } from './index-core.js';
20
18
  import { rrfFuse } from './fuse.js';
21
19
  import { cosineTopK, embedQuery, getEmbedder, loadVectors, vectorsMtimeMs, } from './embed-store.js';
20
+ import { embeddingModelSpec } from './embedding-config.js';
22
21
  import { indexMtimeMs, loadIndex } from './store.js';
23
22
  const CAND = 60; // candidate pool depth per leg before fusion/limit
24
23
  const SNIPPET_WIDTH = 64;
@@ -138,16 +137,6 @@ async function cachedVectors() {
138
137
  }
139
138
  return vectorCache.vectors;
140
139
  }
141
- /** read an optional embeddingModel spec from ~/.sanook/config.json. */
142
- async function configEmbeddingModel() {
143
- try {
144
- const cfg = JSON.parse(await readFile(appHomePath('config.json'), 'utf8'));
145
- return cfg.embeddingModel;
146
- }
147
- catch {
148
- return undefined;
149
- }
150
- }
151
140
  /** drop in-process caches (tests + after a reindex in the same process). */
152
141
  export function resetSearchCaches() {
153
142
  indexCache = null;
@@ -165,7 +154,7 @@ export async function search(query, opts = {}) {
165
154
  const mode = opts.mode ?? 'auto';
166
155
  if (mode === 'fts')
167
156
  return rankSearch(index, query, opts);
168
- const spec = opts.embeddingModel ?? process.env.SANOOK_EMBEDDING_MODEL ?? (await configEmbeddingModel());
157
+ const spec = await embeddingModelSpec(opts.embeddingModel);
169
158
  const embedder = getEmbedder(spec);
170
159
  if (!embedder) {
171
160
  const res = rankSearch(index, query, opts);
@@ -24,7 +24,9 @@ import { loadSkills } from '../skills.js';
24
24
  import { activeFacts, effImportance, loadStore } from '../memory-store.js';
25
25
  import { chunkMarkdown } from './chunk.js';
26
26
  import { addDoc, removeDoc, removeSource } from './index-core.js';
27
+ import { embeddingModelSpec } from './embedding-config.js';
27
28
  import { loadIndex, saveIndex } from './store.js';
29
+ import { buildVectorIndex, embedTexts, getEmbedder, invalidateVectors, saveVectors } from './embed-store.js';
28
30
  /** strip a .md path to a human title fallback when a chunk has no heading. */
29
31
  function fileTitle(rel) {
30
32
  return (rel.split('/').pop() ?? rel).replace(/\.md$/i, '');
@@ -37,6 +39,14 @@ export async function indexVaultFiles(index, manifest, fs) {
37
39
  const next = {};
38
40
  const diff = { added: 0, updated: 0, removed: 0, skipped: 0 };
39
41
  const paths = await fs.listMarkdown();
42
+ // Guard against a momentarily-unreadable vault (unmounted drive / perms blip / wrong cwd): walk()
43
+ // swallows readdir errors and returns [], which would otherwise evict the ENTIRE persisted index
44
+ // via the deletion sweep below. If a non-empty manifest just lost >50% of its files, treat it as a
45
+ // transient read failure and keep the existing index+manifest untouched (recovers on the next pass).
46
+ const manifestSize = Object.keys(manifest).length;
47
+ if (manifestSize > 0 && paths.length < Math.ceil(manifestSize * 0.5)) {
48
+ return { manifest, diff: { added: 0, updated: 0, removed: 0, skipped: manifestSize } };
49
+ }
40
50
  const seenExisting = new Set();
41
51
  for (const rel of paths) {
42
52
  const fp = await fs.fingerprint(rel);
@@ -130,6 +140,21 @@ export function foldSkills(index, skills) {
130
140
  }
131
141
  return skills.length;
132
142
  }
143
+ function docEmbeddingText(doc) {
144
+ return [doc.title?.trim(), doc.text.trim()].filter(Boolean).join('\n').slice(0, 4000);
145
+ }
146
+ export async function vectorizeIndex(index, tag, embed) {
147
+ const docs = [...index.docs.values()]
148
+ .filter((d) => d.text.trim())
149
+ .sort((a, b) => a.id.localeCompare(b.id));
150
+ if (!docs.length)
151
+ return buildVectorIndex(tag, []);
152
+ const vectors = await embed(docs.map(docEmbeddingText));
153
+ if (vectors.length !== docs.length) {
154
+ throw new Error(`embedding count mismatch: expected ${docs.length}, got ${vectors.length}`);
155
+ }
156
+ return buildVectorIndex(tag, docs.map((d, i) => ({ id: d.id, vec: vectors[i] })));
157
+ }
133
158
  // ---- real-filesystem wiring ------------------------------------------------
134
159
  const IGNORE_DIRS = new Set([
135
160
  'node_modules', 'dist', 'build', 'coverage', '.next', '.cache', '.git',
@@ -237,5 +262,23 @@ export async function reindex(now = Date.now()) {
237
262
  text: `${s.description} ${s.whenToUse ?? ''}`.trim(),
238
263
  })));
239
264
  await saveIndex(index, nextManifest);
240
- return { ...diff, memory, sessions, skills, vaultPath: brain ?? null };
265
+ let vectors = 0;
266
+ const embedder = getEmbedder(await embeddingModelSpec());
267
+ if (!embedder) {
268
+ await invalidateVectors().catch(() => { });
269
+ }
270
+ else {
271
+ try {
272
+ const vi = await vectorizeIndex(index, embedder.tag, (texts) => embedTexts(embedder, texts));
273
+ await saveVectors(vi);
274
+ vectors = vi.ids.length;
275
+ }
276
+ catch {
277
+ // Semantic search is optional. A provider/network failure must never break
278
+ // the BM25 floor. Clear stale vectors so hybrid/semantic cannot rank the
279
+ // freshly-saved BM25 index with embeddings from an older corpus.
280
+ await invalidateVectors(embedder.tag).catch(() => { });
281
+ }
282
+ }
283
+ return { ...diff, memory, sessions, skills, vectors, vaultPath: brain ?? null };
241
284
  }
@@ -34,6 +34,28 @@ export async function indexMtimeMs() {
34
34
  return 0;
35
35
  }
36
36
  }
37
+ export function sanitizeManifest(raw) {
38
+ const out = Object.create(null);
39
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
40
+ return out;
41
+ for (const [path, entry] of Object.entries(raw)) {
42
+ if (!path || ['__proto__', 'prototype', 'constructor'].includes(path))
43
+ continue;
44
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
45
+ continue;
46
+ const r = entry;
47
+ const mtimeMs = Number(r.mtimeMs);
48
+ const size = Number(r.size);
49
+ if (!Number.isFinite(mtimeMs) || mtimeMs < 0 || !Number.isFinite(size) || size < 0)
50
+ continue;
51
+ if (typeof r.sha !== 'string' || !r.sha || r.sha.length > 256)
52
+ continue;
53
+ if (!Array.isArray(r.ids) || !r.ids.every((id) => typeof id === 'string' && id.length > 0 && id.length < 1024))
54
+ continue;
55
+ out[path] = { mtimeMs, size, sha: r.sha, ids: [...r.ids] };
56
+ }
57
+ return out;
58
+ }
37
59
  /**
38
60
  * Load the persisted index + manifest. Pure read: a missing or malformed file
39
61
  * degrades to an empty index rather than throwing, so a corrupt cache never
@@ -43,7 +65,7 @@ export async function loadIndex() {
43
65
  try {
44
66
  const raw = JSON.parse(await readFile(INDEX_PATH, 'utf8'));
45
67
  if (raw && raw.v === FILE_VERSION) {
46
- return { index: indexFromJSON(raw.index), manifest: raw.manifest ?? {} };
68
+ return { index: indexFromJSON(raw.index), manifest: sanitizeManifest(raw.manifest) };
47
69
  }
48
70
  }
49
71
  catch {
@@ -0,0 +1,84 @@
1
+ // Session knowledge distiller — extracts DURABLE facts (decisions, gotchas, preferences,
2
+ // constraints) from a finished session transcript so they can be folded into the memory store
3
+ // WITHOUT the model voluntarily calling `remember`. Pure + deterministic (heuristic): the offline,
4
+ // zero-cost fallback. An LLM-based extractor can layer on top when a model is available.
5
+ // Signal patterns — a sentence is a candidate only if it matches one (keeps precision up).
6
+ const SIGNALS = [
7
+ { kind: 'decision', re: /\b(decided|we['’]?ll use|we will use|we use|going with|chose|switch(?:ed|ing)? to|standardiz(?:e|ed|ing) on|settled on|agreed to)\b/i },
8
+ { kind: 'preference', re: /\b(prefer(?:s|red)?|convention(?: is|:)|by convention|coding style|likes? to|always (?:use|run|prefer|name)|we name)\b/i },
9
+ { kind: 'constraint', re: /\b(must not|must|never|do ?n['’]?t|don['’]t|required to|is required|only (?:use|run|allow)|forbidden|not allowed|has to)\b/i },
10
+ { kind: 'gotcha', re: /\b(gotcha|caveat|watch out|the (?:bug|issue|problem|error) (?:was|is)|turned out|root cause|fix(?:ed)? (?:was|by|it by)|fails? (?:if|when|because)|breaks? (?:if|when)|broke because|because the|note that|important:|heads up)\b/i },
11
+ ];
12
+ // Strong "X not Y" / "X instead of Y" decision signal (e.g. "pnpm not npm", "tabs over spaces").
13
+ const X_NOT_Y = /\b[\w.@/+-]{2,}\s+(?:not|instead of|over|rather than)\s+[\w.@/+-]{2,}\b/i;
14
+ const MAX_CANDIDATES = 12;
15
+ const MIN_WORDS = 4;
16
+ const MAX_WORDS = 45;
17
+ function looksLikeCodeOrLog(s) {
18
+ if (/^\s*[$#>]/.test(s))
19
+ return true; // shell prompt / diff marker
20
+ if (/[{};=]\s*$/.test(s) && /[(){}\[\]=;]/.test(s))
21
+ return true; // code-ish line
22
+ if (/\b(at |Error:|Traceback|stack trace|node_modules\/)/.test(s) && /:\d+/.test(s))
23
+ return true; // stack trace
24
+ const symbolRatio = (s.replace(/[\w\s]/g, '').length || 0) / Math.max(1, s.length);
25
+ return symbolRatio > 0.3; // mostly punctuation/symbols
26
+ }
27
+ function splitSentences(text) {
28
+ return text
29
+ .split(/(?<=[.!?])\s+|\n+/)
30
+ .map((s) => s.trim())
31
+ .filter(Boolean);
32
+ }
33
+ function normalize(s) {
34
+ return s.replace(/\s+/g, ' ').replace(/^[-*•\d.)\s]+/, '').trim();
35
+ }
36
+ /**
37
+ * Extract durable-fact candidates from a transcript. Skips questions, chit-chat, code/log lines,
38
+ * and too-short/too-long sentences; requires a decision/gotcha/preference/constraint signal.
39
+ */
40
+ export function distillSession(messages) {
41
+ const out = [];
42
+ const seen = new Set();
43
+ for (const msg of messages) {
44
+ if (msg.role !== 'user' && msg.role !== 'assistant')
45
+ continue;
46
+ for (const raw of splitSentences(msg.text)) {
47
+ const s = normalize(raw);
48
+ const words = s.split(/\s+/).filter(Boolean);
49
+ if (words.length < MIN_WORDS || words.length > MAX_WORDS)
50
+ continue;
51
+ if (s.endsWith('?'))
52
+ continue; // questions aren't durable facts
53
+ if (looksLikeCodeOrLog(s))
54
+ continue;
55
+ const signal = SIGNALS.find((sig) => sig.re.test(s));
56
+ const kind = signal?.kind ?? (X_NOT_Y.test(s) ? 'decision' : undefined);
57
+ if (!kind)
58
+ continue;
59
+ const key = s.toLowerCase().replace(/[^a-z0-9 ]/g, '').trim();
60
+ if (!key || seen.has(key))
61
+ continue;
62
+ seen.add(key);
63
+ out.push({ text: s, kind });
64
+ if (out.length >= MAX_CANDIDATES)
65
+ return out;
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+ /** flatten an AI-SDK ModelMessage content (string | parts[]) to its plain text. */
71
+ function messageText(content) {
72
+ if (typeof content === 'string')
73
+ return content;
74
+ if (!Array.isArray(content))
75
+ return '';
76
+ return content
77
+ .map((p) => (p && typeof p === 'object' && 'text' in p && typeof p.text === 'string' ? p.text : ''))
78
+ .join(' ')
79
+ .trim();
80
+ }
81
+ /** distill durable-fact texts from a finished conversation (ModelMessage[]-shaped). Pure. */
82
+ export function distilledFactsFromMessages(messages) {
83
+ return distillSession(messages.map((m) => ({ role: m.role, text: messageText(m.content) }))).map((c) => c.text);
84
+ }