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
@@ -1,21 +1,48 @@
1
- // ============================================================================
2
- // src/orchestrate.ts — subagent ORCHESTRATION (parallel fan-out + background).
3
- //
4
- // The single Task subagent (src/tools/task.ts) is one-shot and synchronous. This
5
- // module adds the two missing orchestration primitives a frontier harness needs:
6
- // 1. runParallel() — fan a list of subagents out concurrently with a real
7
- // concurrency cap and PER-ITEM error isolation (one failure never sinks the
8
- // batch), results returned in input order.
9
- // 2. TaskRegistry — fire-and-forget BACKGROUND subagents: spawn() returns an id
10
- // immediately, the work runs detached, and collect()/list()/cancel() let the
11
- // main agent keep working and gather results later in the same session.
12
- //
13
- // Everything is PURE w.r.t. the actual agent: the subagent runner is INJECTED
14
- // (SubagentRunner), and the clock + id generator are injectable, so the whole
15
- // orchestration layer unit-tests with a fake runner — zero model calls, zero
16
- // network — exactly like the search subsystem injects its fs.
17
- // ============================================================================
1
+ import { inspect } from 'node:util';
2
+ export function formatSubagentError(e) {
3
+ if (e instanceof Error)
4
+ return e.message || e.name;
5
+ if (typeof e === 'string')
6
+ return e;
7
+ if (e == null)
8
+ return String(e);
9
+ try {
10
+ const json = JSON.stringify(e);
11
+ if (json)
12
+ return json;
13
+ }
14
+ catch {
15
+ return inspect(e, { breakLength: Infinity, depth: 2 });
16
+ }
17
+ return String(e);
18
+ }
18
19
  const DEFAULT_CONCURRENCY = 5;
20
+ const DEFAULT_GLOBAL_SUBAGENT_CONCURRENCY = 16;
21
+ let globalInFlight = 0;
22
+ const globalWaiters = [];
23
+ function globalSubagentLimit() {
24
+ const raw = process.env.SANOOK_SUBAGENT_CONCURRENCY?.trim();
25
+ if (!raw || !/^\d+$/.test(raw))
26
+ return DEFAULT_GLOBAL_SUBAGENT_CONCURRENCY;
27
+ const parsed = Number(raw);
28
+ return Number.isSafeInteger(parsed) && parsed > 0 ? Math.min(parsed, 64) : DEFAULT_GLOBAL_SUBAGENT_CONCURRENCY;
29
+ }
30
+ export function globalSubagentRunningCount() {
31
+ return globalInFlight;
32
+ }
33
+ export async function withGlobalSubagentSlot(fn) {
34
+ while (globalInFlight >= globalSubagentLimit()) {
35
+ await new Promise((resolve) => globalWaiters.push(resolve));
36
+ }
37
+ globalInFlight++;
38
+ try {
39
+ return await fn();
40
+ }
41
+ finally {
42
+ globalInFlight--;
43
+ globalWaiters.shift()?.();
44
+ }
45
+ }
19
46
  /**
20
47
  * Run thunks concurrently, capped at `concurrency`, results in input order.
21
48
  * The generic concurrency primitive both runParallel and worktree isolation use.
@@ -41,7 +68,7 @@ async function runOne(spec, runner, signal) {
41
68
  return { ok: true, description: spec.description, text };
42
69
  }
43
70
  catch (e) {
44
- return { ok: false, description: spec.description, text: '', error: e.message };
71
+ return { ok: false, description: spec.description, text: '', error: formatSubagentError(e) };
45
72
  }
46
73
  }
47
74
  /**
@@ -89,7 +116,7 @@ export class TaskRegistry {
89
116
  catch (e) {
90
117
  const cur = this.tasks.get(id);
91
118
  if (cur.state !== 'canceled')
92
- Object.assign(cur, { state: 'error', error: e.message, endedMs: this.now() });
119
+ Object.assign(cur, { state: 'error', error: formatSubagentError(e), endedMs: this.now() });
93
120
  return cur;
94
121
  }
95
122
  finally {
@@ -116,6 +143,9 @@ export class TaskRegistry {
116
143
  const settle = this.settles.get(id);
117
144
  if (!settle)
118
145
  return undefined;
146
+ const current = this.tasks.get(id);
147
+ if (current && current.state !== 'running')
148
+ return current;
119
149
  if (timeoutMs == null)
120
150
  return settle;
121
151
  let timer;
@@ -0,0 +1,58 @@
1
+ export const PERSONALITIES = {
2
+ concise: {
3
+ label: 'Concise',
4
+ prompt: 'Keep responses short, direct, and practical. Prefer the smallest useful answer.',
5
+ },
6
+ friendly: {
7
+ label: 'Friendly',
8
+ prompt: 'Use a warm, encouraging tone while staying precise and useful.',
9
+ },
10
+ formal: {
11
+ label: 'Formal',
12
+ prompt: 'Use a polished, professional tone. Avoid slang and keep recommendations structured.',
13
+ },
14
+ direct: {
15
+ label: 'Direct',
16
+ prompt: 'Be blunt, decisive, and action-oriented. Lead with the answer and avoid hedging.',
17
+ },
18
+ teacher: {
19
+ label: 'Teacher',
20
+ prompt: 'Explain the reasoning clearly and teach as you go, without becoming long-winded.',
21
+ },
22
+ researcher: {
23
+ label: 'Researcher',
24
+ prompt: 'Be evidence-minded. Distinguish facts, assumptions, and uncertainty clearly.',
25
+ },
26
+ creative: {
27
+ label: 'Creative',
28
+ prompt: 'Offer imaginative options and phrasing while keeping the implementation grounded.',
29
+ },
30
+ thai: {
31
+ label: 'Thai-first',
32
+ prompt: 'Prefer Thai for user-facing prose unless the user asks for another language.',
33
+ },
34
+ };
35
+ export function normalizePersonalityName(raw) {
36
+ const name = raw?.trim().toLowerCase();
37
+ if (!name)
38
+ return null;
39
+ if (['none', 'default', 'neutral', 'off', 'clear'].includes(name))
40
+ return 'none';
41
+ return PERSONALITIES[name] ? name : null;
42
+ }
43
+ export function personalityPrompt(name) {
44
+ const normalized = normalizePersonalityName(name);
45
+ if (!normalized || normalized === 'none')
46
+ return '';
47
+ const def = PERSONALITIES[normalized];
48
+ return def ? `Personality overlay (${def.label}): ${def.prompt}` : '';
49
+ }
50
+ export function personalityListText() {
51
+ return [
52
+ 'personality ที่เลือกได้:',
53
+ ' none — ปิด personality overlay',
54
+ ...Object.entries(PERSONALITIES).map(([name, def]) => ` ${name} — ${def.label}`),
55
+ '',
56
+ 'ใช้: /personality <name>',
57
+ ].join('\n');
58
+ }
@@ -2,18 +2,32 @@ import { spawn } from 'node:child_process';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ export function codexHome() {
6
+ return process.env.CODEX_HOME?.trim() || join(homedir(), '.codex');
7
+ }
5
8
  /** เช็กว่า codex CLI ติดตั้ง + login ChatGPT แล้ว */
6
9
  export async function detectCodex() {
7
10
  const hasBinary = await new Promise((resolve) => {
8
11
  const p = spawn('codex', ['--version'], { shell: process.platform === 'win32' });
9
- p.on('error', () => resolve(false));
10
- p.on('close', (code) => resolve(code === 0));
12
+ // timeout: binary ค้าง (shim รอ stdin / Gatekeeper stall ตอนรันครั้งแรกบน macOS) ไม่ให้ wizard ตัน
13
+ const timer = setTimeout(() => {
14
+ p.kill();
15
+ resolve(false);
16
+ }, 5000);
17
+ p.on('error', () => {
18
+ clearTimeout(timer);
19
+ resolve(false);
20
+ });
21
+ p.on('close', (code) => {
22
+ clearTimeout(timer);
23
+ resolve(code === 0);
24
+ });
11
25
  });
12
26
  if (!hasBinary) {
13
27
  return { installed: false, loggedIn: false, reason: 'ไม่พบ codex CLI — ติดตั้ง: npm i -g @openai/codex' };
14
28
  }
15
29
  try {
16
- const auth = JSON.parse(await readFile(join(homedir(), '.codex', 'auth.json'), 'utf8'));
30
+ const auth = JSON.parse(await readFile(join(codexHome(), 'auth.json'), 'utf8'));
17
31
  const loggedIn = auth?.auth_mode === 'chatgpt' || Boolean(auth?.tokens?.access_token);
18
32
  return { installed: true, loggedIn, reason: loggedIn ? undefined : 'ยังไม่ได้ login — รัน: codex login' };
19
33
  }
@@ -26,6 +40,7 @@ export async function detectCodex() {
26
40
  * tolerant ต่อ malformed JSONL (codex bug #15451: --json ถูก ignore เมื่อมี tools active)
27
41
  */
28
42
  export async function runCodex(opts) {
43
+ // `codex exec` is already non-interactive; sandbox controls whether generated commands may write.
29
44
  const args = ['exec', '--skip-git-repo-check', '--sandbox', opts.sandbox ?? 'read-only', '--json'];
30
45
  if (opts.model)
31
46
  args.push('-m', opts.model);
@@ -33,54 +48,87 @@ export async function runCodex(opts) {
33
48
  args.push('resume', opts.resumeThreadId);
34
49
  args.push('-'); // prompt via stdin
35
50
  return new Promise((resolve, reject) => {
36
- // OPENAI_API_KEY='' กัน BYOK key ของ Sanook ไป override ChatGPT login ของ codex
37
- const env = { ...process.env, OPENAI_API_KEY: '' };
38
- const p = spawn('codex', args, { env, shell: process.platform === 'win32' }); // Windows: codex.cmd
51
+ // ลบ OPENAI_API_KEY ออกจาก env ของ child — กัน BYOK key ของ Sanook ไป override/ชนกับ ChatGPT login
52
+ // (codex bug #2733/#3286: ตั้ง OPENAI_API_KEY ค้าง env ทำให้ ChatGPT-plan auth วน loop sign-in)
53
+ const env = { ...process.env };
54
+ delete env.OPENAI_API_KEY;
55
+ const p = spawn('codex', args, { env, cwd: opts.cwd, shell: process.platform === 'win32' }); // Windows: codex = JS shim ผ่าน .cmd → ต้อง shell
39
56
  let finalText = '';
40
57
  let threadId;
41
58
  let buf = '';
42
- opts.signal?.addEventListener('abort', () => p.kill());
43
- p.stdin.write(opts.prompt);
44
- p.stdin.end();
59
+ let stderr = '';
60
+ let aborted = false;
61
+ const handleStdoutLine = (line) => {
62
+ const t = line.trim();
63
+ if (!t)
64
+ return;
65
+ if (!t.startsWith('{')) {
66
+ // plain stdout fallback (JSONL ถูก ignore) — เก็บเป็น final text
67
+ finalText += (finalText ? '\n' : '') + t;
68
+ opts.onEvent?.({ type: 'text', text: finalText });
69
+ return;
70
+ }
71
+ try {
72
+ const ev = JSON.parse(t);
73
+ if (ev.type === 'thread.started' && ev.thread_id) {
74
+ threadId = ev.thread_id;
75
+ opts.onEvent?.({ type: 'thread', threadId });
76
+ }
77
+ else if (ev.type === 'item.completed' && ev.item?.type === 'agent_message') {
78
+ finalText = ev.item.text ?? finalText;
79
+ opts.onEvent?.({ type: 'text', text: finalText });
80
+ }
81
+ else if (ev.type === 'turn.completed') {
82
+ opts.onEvent?.({ type: 'usage', usage: ev.usage });
83
+ }
84
+ }
85
+ catch {
86
+ // malformed JSON line — ข้าม
87
+ }
88
+ };
89
+ const abortHandler = () => {
90
+ aborted = true;
91
+ p.kill();
92
+ };
93
+ const cleanupAbortHandler = () => opts.signal?.removeEventListener('abort', abortHandler);
94
+ // codex อาจตายระหว่างรับ prompt → write ลง stdin ที่ปิดแล้ว = EPIPE; ถ้าไม่ดัก = crash ทั้ง CLI
95
+ // (close handler ด้านล่าง reject error ที่อ่านรู้เรื่องแทน)
96
+ p.stdin.on('error', () => { });
97
+ if (opts.signal?.aborted)
98
+ abortHandler();
99
+ else
100
+ opts.signal?.addEventListener('abort', abortHandler, { once: true });
101
+ if (!aborted) {
102
+ p.stdin.write(opts.prompt);
103
+ p.stdin.end();
104
+ }
45
105
  p.stdout.on('data', (chunk) => {
46
106
  buf += chunk.toString();
47
107
  const lines = buf.split('\n');
48
108
  buf = lines.pop() ?? '';
49
109
  for (const line of lines) {
50
- const t = line.trim();
51
- if (!t)
52
- continue;
53
- if (!t.startsWith('{')) {
54
- // plain stdout fallback (JSONL ถูก ignore) — เก็บเป็น final text
55
- finalText += (finalText ? '\n' : '') + t;
56
- opts.onEvent?.({ type: 'text', text: finalText });
57
- continue;
58
- }
59
- try {
60
- const ev = JSON.parse(t);
61
- if (ev.type === 'thread.started' && ev.thread_id) {
62
- threadId = ev.thread_id;
63
- opts.onEvent?.({ type: 'thread', threadId });
64
- }
65
- else if (ev.type === 'item.completed' && ev.item?.type === 'agent_message') {
66
- finalText = ev.item.text ?? finalText;
67
- opts.onEvent?.({ type: 'text', text: finalText });
68
- }
69
- else if (ev.type === 'turn.completed') {
70
- opts.onEvent?.({ type: 'usage', usage: ev.usage });
71
- }
72
- }
73
- catch {
74
- // malformed JSON line — ข้าม
75
- }
110
+ handleStdoutLine(line);
76
111
  }
77
112
  });
78
- p.on('error', (err) => reject(new Error(`เรียก codex ไม่ได้: ${err.message}`)));
113
+ p.stderr.on('data', (chunk) => {
114
+ stderr += chunk.toString();
115
+ if (stderr.length > 4000)
116
+ stderr = stderr.slice(-4000);
117
+ });
118
+ p.on('error', (err) => {
119
+ cleanupAbortHandler();
120
+ reject(new Error(`เรียก codex ไม่ได้: ${err.message}`));
121
+ });
79
122
  p.on('close', (code) => {
80
- if (code === 0)
123
+ cleanupAbortHandler();
124
+ handleStdoutLine(buf);
125
+ buf = '';
126
+ if (aborted)
127
+ reject(new Error(`codex exec ถูกยกเลิก${stderr.trim() ? `: ${stderr.trim()}` : ''}`));
128
+ else if (code === 0)
81
129
  resolve({ text: finalText.trim(), threadId });
82
130
  else
83
- reject(new Error(`codex exec จบด้วย exit code ${code}`));
131
+ reject(new Error(`codex exec จบด้วย exit code ${code}${stderr.trim() ? `: ${stderr.trim()}` : ''}`));
84
132
  });
85
133
  });
86
134
  }
@@ -8,7 +8,7 @@
8
8
  /** อ่าน API key จาก env (หลัก + fallbacks) — keychain เป็น enhancement ทีหลัง */
9
9
  export function resolveKeyFromEnv(envVar, fallbacks = []) {
10
10
  for (const name of [envVar, ...fallbacks]) {
11
- const v = process.env[name];
11
+ const v = process.env[name]?.trim();
12
12
  if (v)
13
13
  return v;
14
14
  }
@@ -43,13 +43,29 @@ export async function listRemoteModels(cfg, key, timeoutMs = 6000) {
43
43
  }
44
44
  /**
45
45
  * merge: curated alias (registry — มี label สื่อความหมาย) นำหน้า + remote id ที่เหลือต่อท้าย
46
- * dedup ด้วย model id (ไม่โชว์ id ซ้ำสองครั้ง). ใช้ทั้ง setup wizard และ /model picker
46
+ * dedup ด้วย model id alias หลายตัวที่ชี้ id เดียวกัน (เช่น haiku/fast → claude-haiku-4-5,
47
+ * smart/gpt → gpt-5.5) ต้องรวมเป็น "haiku / fast — id" บรรทัดเดียว ไม่งั้น value ซ้ำ → React key ชน
48
+ * → ตัวเลือกโผล่ซ้ำ/หาย (bug "มีตัวเลือกสองตัวเลือกเป็น model เดียวกัน"). ใช้ทั้ง setup wizard และ /model picker
47
49
  */
48
50
  export function mergeModelOptions(cfg, remote = []) {
49
- const curated = Object.entries(cfg.models)
50
- .filter(([alias]) => alias !== 'default')
51
- .map(([alias, id]) => ({ id, label: `${alias} — ${id}` }));
52
- const seen = new Set(curated.map((c) => c.id));
53
- const extra = remote.filter((id) => !seen.has(id)).map((id) => ({ id, label: id }));
51
+ // group alias ทั้งหมดตาม id (รวม 'default' ด้วย — กัน id ที่มีแต่ alias 'default' เช่น lmstudio:local-model,
52
+ // ollama:llama3.3 หายไปจนเลือกไม่ได้/Select ว่าง). ตอนทำ label ค่อยซ่อนคำ "default" ถ้ามีชื่ออื่นอยู่แล้ว
53
+ const aliasesById = new Map();
54
+ const order = []; // คง first-seen order ของ id
55
+ for (const [alias, id] of Object.entries(cfg.models)) {
56
+ if (!aliasesById.has(id)) {
57
+ aliasesById.set(id, []);
58
+ order.push(id);
59
+ }
60
+ aliasesById.get(id)?.push(alias);
61
+ }
62
+ const curated = order.map((id) => {
63
+ const aliases = aliasesById.get(id) ?? [];
64
+ const named = aliases.filter((a) => a !== 'default');
65
+ const shown = named.length ? named : aliases; // มีแต่ 'default' → โชว์ 'default' (ดีกว่าซ่อน id หายไป)
66
+ return { id, label: `${shown.join(' / ')} — ${id}` };
67
+ });
68
+ const seen = new Set(order);
69
+ const extra = [...new Set(remote)].filter((id) => id && !seen.has(id)).map((id) => ({ id, label: id }));
54
70
  return [...curated, ...extra].map((o) => ({ label: o.label, value: o.id }));
55
71
  }
@@ -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,26 +181,51 @@ 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];
238
199
  }
239
- /** หา provider ที่ "มี key ใน env แล้ว" (cloud, ตามลำดับนิยม) — ใช้ทำ first-run smart skip + แนะ headless */
200
+ /**
201
+ * provider นี้มี key ใน env ที่ "ใช้ได้จริง" ไหม — มี key + ผ่าน policy (ไม่ใช่ OAuth/subscription token
202
+ * หรือ format ผิด). ใช้ทั้ง first-run smart-skip และ -m flag เพื่อไม่ให้ข้าม wizard ทั้งที่ key ใช้ไม่ได้
203
+ * (เช่น export ANTHROPIC_API_KEY=sk-ant-oat… → ถูกแบน → ต้องเข้า wizard ไม่ใช่ขึ้น "พร้อมใช้")
204
+ */
205
+ export function hasUsableEnvKey(provider) {
206
+ const cfg = PROVIDERS[provider];
207
+ if (!cfg)
208
+ return false;
209
+ if (cfg.kind === 'delegate')
210
+ return false; // ต้องเช็ก readiness แยก (เช่น codex CLI installed + logged in)
211
+ if (!cfg.requiresKey)
212
+ return true; // local — ไม่ต้อง key
213
+ const k = resolveKeyFromEnv(cfg.envVar, cfg.envFallbacks);
214
+ if (!k)
215
+ return false;
216
+ try {
217
+ assertDirectApiKey(cfg, k); // reject OAuth prefix / format ผิด
218
+ return true;
219
+ }
220
+ catch {
221
+ return false;
222
+ }
223
+ }
224
+ /** หา provider ที่ "มี key ใช้ได้จริงใน env" (cloud, ตามลำดับนิยม) — ใช้ทำ first-run smart skip + แนะ headless */
240
225
  export function detectEnvProvider() {
241
- for (const id of ['anthropic', 'openai', 'google', 'deepseek', 'xai', 'mistral', 'groq', 'glm', 'minimax']) {
226
+ for (const id of ['anthropic', 'openai', 'google', 'xai', 'mistral', 'groq']) {
242
227
  const cfg = PROVIDERS[id];
243
- if (cfg?.requiresKey && resolveKeyFromEnv(cfg.envVar, cfg.envFallbacks)) {
228
+ if (cfg?.requiresKey && hasUsableEnvKey(id)) {
244
229
  return { provider: id, label: cfg.label, envVar: cfg.envVar, model: cfg.models.default };
245
230
  }
246
231
  }
@@ -270,10 +255,14 @@ export function resolveModel(spec) {
270
255
  const found = resolveKeyFromEnv(cfg.envVar, cfg.envFallbacks);
271
256
  if (!found) {
272
257
  const url = consoleUrl(provider);
258
+ const codexHint = provider === 'openai'
259
+ ? `\n • ถ้าต้องการใช้ ChatGPT plan ไม่ใช้ API key: เลือก \`/model codex\` แล้วรัน \`codex login\``
260
+ : '';
273
261
  throw new Error(`ยังไม่มี API key ของ ${cfg.label} (${cfg.envVar})\n` +
274
262
  (url ? ` • เอา key ที่: ${url}\n` : '') +
275
263
  ` • ตั้ง: export ${cfg.envVar}="..." ` +
276
- `หรือรัน \`${BRAND.cliName}\` (ไม่ใส่ task) เพื่อ setup wizard`);
264
+ `หรือรัน \`${BRAND.cliName}\` (ไม่ใส่ task) เพื่อ setup wizard` +
265
+ codexHint);
277
266
  }
278
267
  assertDirectApiKey(cfg, found); // reject OAuth/subscription token + format ผิด
279
268
  key = found;
@@ -281,7 +270,7 @@ export function resolveModel(spec) {
281
270
  else {
282
271
  key = resolveKeyFromEnv(cfg.envVar) ?? cfg.localPlaceholderKey ?? 'local';
283
272
  }
284
- // <PROVIDER>_BASE_URL env → override (สลับ region intl/จีน); ไม่งั้น local อ่าน env, cloud ใช้ default
273
+ // <PROVIDER>_BASE_URL env → override (สลับ region); ไม่งั้น local อ่าน env, cloud ใช้ default
285
274
  const baseURL = process.env[`${cfg.id.toUpperCase()}_BASE_URL`] ??
286
275
  (cfg.requiresKey ? cfg.baseURL : process.env[cfg.envVar] ?? cfg.baseURL);
287
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,75 @@
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 limit = 8;
23
+ let sources;
24
+ for (let i = 0; i < args.length; i++) {
25
+ const a = args[i];
26
+ if (a === '--') {
27
+ queryParts.push(...args.slice(i + 1));
28
+ break;
29
+ }
30
+ else if (a === '--mode' || a.startsWith('--mode=')) {
31
+ const next = a === '--mode' ? takeValue(args, i) : undefined;
32
+ const v = next ? next.value : inlineValue('--mode', a);
33
+ if (next)
34
+ i = next.nextIndex;
35
+ if (!v)
36
+ return { ok: false, message: `--mode ต้องระบุค่าเป็น ${SEARCH_MODES.join('|')}` };
37
+ if (!isSearchMode(v))
38
+ return { ok: false, message: `--mode ต้องเป็น ${SEARCH_MODES.join('|')}` };
39
+ mode = v;
40
+ }
41
+ else if (a === '--limit' || a.startsWith('--limit=')) {
42
+ const next = a === '--limit' ? takeValue(args, i) : undefined;
43
+ const raw = next ? next.value : inlineValue('--limit', a);
44
+ if (next)
45
+ i = next.nextIndex;
46
+ if (!raw)
47
+ return { ok: false, message: '--limit ต้องระบุค่าเป็น integer บวก เช่น 8' };
48
+ const n = parsePositiveInteger(raw);
49
+ if (n === undefined)
50
+ return { ok: false, message: '--limit ต้องเป็น integer บวก เช่น 8' };
51
+ limit = n;
52
+ }
53
+ else if (a === '--source' || a === '--sources' || a.startsWith('--source=') || a.startsWith('--sources=')) {
54
+ const next = a === '--source' || a === '--sources' ? takeValue(args, i) : undefined;
55
+ const raw = next ? next.value : inlineSourceValue(a);
56
+ if (next)
57
+ i = next.nextIndex;
58
+ const requested = (raw ?? '').split(',').map((s) => s.trim()).filter(Boolean);
59
+ const bad = requested.filter((s) => !isSearchSource(s));
60
+ if (!requested.length) {
61
+ return { ok: false, message: `--source ต้องระบุค่าเป็น ${SEARCH_SOURCES.join(',')} (คั่นหลายค่าได้ด้วย comma)` };
62
+ }
63
+ if (bad.length)
64
+ return { ok: false, message: `--source ต้องเป็น ${SEARCH_SOURCES.join(',')} (คั่นหลายค่าได้ด้วย comma)` };
65
+ sources = [...new Set(requested)];
66
+ }
67
+ else {
68
+ queryParts.push(a);
69
+ }
70
+ }
71
+ const query = queryParts.join(' ').trim();
72
+ if (!query)
73
+ return { ok: false, message: 'ต้องใส่ query สำหรับค้นหา' };
74
+ return { ok: true, value: { query, mode, limit, sources } };
75
+ }
@@ -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;