sanook-cli 0.5.2 → 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 (119) hide show
  1. package/CHANGELOG.md +91 -2
  2. package/README.md +15 -3
  3. package/README.th.md +8 -1
  4. package/dist/approval.js +7 -0
  5. package/dist/bin.js +623 -56
  6. package/dist/brain-consolidate.js +335 -0
  7. package/dist/brain-context.js +42 -3
  8. package/dist/brain-final.js +15 -9
  9. package/dist/brain-metrics.js +277 -0
  10. package/dist/brain-new.js +402 -0
  11. package/dist/brain-pack.js +210 -0
  12. package/dist/brain-repair.js +280 -0
  13. package/dist/brain.js +3 -0
  14. package/dist/cli-args.js +47 -9
  15. package/dist/cli-option-values.js +1 -1
  16. package/dist/clipboard.js +65 -0
  17. package/dist/commands.js +94 -14
  18. package/dist/config.js +31 -5
  19. package/dist/context-pack.js +145 -0
  20. package/dist/dashboard/api-helpers.js +87 -0
  21. package/dist/dashboard/server.js +179 -0
  22. package/dist/dashboard/static/app.js +277 -0
  23. package/dist/dashboard/static/index.html +39 -0
  24. package/dist/dashboard/static/styles.css +85 -0
  25. package/dist/diff.js +10 -2
  26. package/dist/gateway/auth.js +14 -3
  27. package/dist/gateway/deliver.js +45 -3
  28. package/dist/gateway/doctor.js +456 -0
  29. package/dist/gateway/email.js +30 -1
  30. package/dist/gateway/ledger.js +20 -1
  31. package/dist/gateway/session.js +30 -11
  32. package/dist/hotkeys.js +21 -0
  33. package/dist/i18n/en.js +98 -0
  34. package/dist/i18n/index.js +19 -0
  35. package/dist/i18n/th.js +98 -0
  36. package/dist/i18n/types.js +1 -0
  37. package/dist/insights-args.js +24 -4
  38. package/dist/knowledge.js +55 -29
  39. package/dist/loop.js +34 -5
  40. package/dist/mcp-hub.js +33 -0
  41. package/dist/mcp-registry.js +153 -9
  42. package/dist/mcp-risk.js +71 -0
  43. package/dist/mcp.js +77 -5
  44. package/dist/memory-log.js +90 -0
  45. package/dist/memory-store.js +37 -1
  46. package/dist/memory.js +51 -7
  47. package/dist/model-picker.js +58 -0
  48. package/dist/orchestrate.js +7 -5
  49. package/dist/plan-handoff.js +17 -0
  50. package/dist/polyglot.js +162 -0
  51. package/dist/process-runner.js +96 -0
  52. package/dist/project-init.js +91 -0
  53. package/dist/project-registry.js +143 -0
  54. package/dist/project-scaffold.js +124 -0
  55. package/dist/prompt-size.js +155 -0
  56. package/dist/providers/codex-login.js +138 -0
  57. package/dist/providers/codex.js +20 -8
  58. package/dist/providers/keys.js +21 -0
  59. package/dist/providers/models.js +1 -1
  60. package/dist/search/cli.js +9 -1
  61. package/dist/search/embedding-config.js +22 -0
  62. package/dist/search/engine.js +2 -13
  63. package/dist/search/indexer.js +10 -10
  64. package/dist/session-distill.js +84 -0
  65. package/dist/session.js +1 -11
  66. package/dist/skill-install.js +24 -1
  67. package/dist/skills.js +33 -0
  68. package/dist/slash-completion.js +155 -0
  69. package/dist/support-dump.js +31 -0
  70. package/dist/tool-catalog.js +59 -0
  71. package/dist/tools/index.js +5 -0
  72. package/dist/tools/permission.js +82 -16
  73. package/dist/tools/polyglot.js +126 -0
  74. package/dist/tools/sandbox.js +38 -13
  75. package/dist/tools/search.js +9 -2
  76. package/dist/tools/task.js +22 -2
  77. package/dist/tools/timeout.js +7 -5
  78. package/dist/tools/web-fetch-tool.js +33 -0
  79. package/dist/turn-retrieval.js +83 -0
  80. package/dist/ui/app.js +835 -29
  81. package/dist/ui/banner.js +78 -4
  82. package/dist/ui/markdown.js +122 -0
  83. package/dist/ui/overlay.js +496 -0
  84. package/dist/ui/queue.js +23 -0
  85. package/dist/ui/render.js +20 -1
  86. package/dist/ui/session-panel.js +115 -0
  87. package/dist/ui/setup-providers.js +40 -0
  88. package/dist/ui/setup.js +163 -50
  89. package/dist/ui/status.js +142 -0
  90. package/dist/ui/thinking-panel.js +36 -0
  91. package/dist/ui/tool-trail.js +97 -0
  92. package/dist/ui/transcript.js +26 -0
  93. package/dist/ui/useBusyElapsed.js +19 -0
  94. package/dist/ui/useEditor.js +144 -5
  95. package/dist/ui/useGitBranch.js +57 -0
  96. package/dist/update.js +32 -6
  97. package/dist/web-fetch.js +637 -0
  98. package/dist/web-surface.js +190 -0
  99. package/package.json +2 -2
  100. package/second-brain/Projects/_Index.md +17 -4
  101. package/second-brain/Projects/sanook-cli/_Index.md +7 -3
  102. package/second-brain/Projects/sanook-cli/context.md +35 -0
  103. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  104. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  105. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  106. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +52 -11
  107. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  108. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  109. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  110. package/second-brain/Research/_Index.md +2 -0
  111. package/second-brain/Shared/Operating-State/current-state.md +14 -23
  112. package/second-brain/Shared/Tech-Standards/_Index.md +2 -0
  113. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  114. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  115. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  116. package/second-brain/Templates/project-workspace/context.md +28 -0
  117. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  118. package/second-brain/Templates/project-workspace/overview.md +39 -0
  119. package/second-brain/Templates/project-workspace/repo.md +33 -0
@@ -0,0 +1,124 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ const TEMPLATE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', 'second-brain', 'Templates', 'project-workspace');
5
+ const WORKSPACE_FILES = ['_Index.md', 'overview.md', 'current-state.md', 'context.md', 'repo.md'];
6
+ export function slugifyProject(value) {
7
+ const slug = value
8
+ .normalize('NFKD')
9
+ .toLowerCase()
10
+ .replace(/[^\p{Letter}\p{Number}]+/gu, '-')
11
+ .replace(/^-+|-+$/g, '')
12
+ .slice(0, 80)
13
+ .replace(/-+$/g, '');
14
+ return slug || 'project';
15
+ }
16
+ function renderTemplate(raw, vars) {
17
+ let out = raw;
18
+ for (const [key, value] of Object.entries(vars)) {
19
+ out = out.replaceAll(`{{${key}}}`, value);
20
+ }
21
+ return out;
22
+ }
23
+ async function readTemplate(name) {
24
+ return readFile(join(TEMPLATE_ROOT, name), 'utf8');
25
+ }
26
+ async function fileExists(path) {
27
+ try {
28
+ await readFile(path);
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ async function maybeAppendProjectsIndex(brainPath, slug, title) {
36
+ const indexPath = join(brainPath, 'Projects', '_Index.md');
37
+ let content;
38
+ try {
39
+ content = await readFile(indexPath, 'utf8');
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ const link = `[[Projects/${slug}/_Index]]`;
45
+ if (content.includes(link))
46
+ return false;
47
+ const line = `- ${link} — ${title}`;
48
+ const marker = 'up:: [[Home]]';
49
+ const next = content.includes(marker) ? content.replace(marker, `${line}\n\n${marker}`) : `${content.trimEnd()}\n${line}\n`;
50
+ await writeFile(indexPath, next, 'utf8');
51
+ return true;
52
+ }
53
+ export async function scaffoldProjectWorkspace(options) {
54
+ const brainPath = resolve(options.brainPath);
55
+ const title = options.title.trim() || 'Project';
56
+ const slug = options.slug?.trim() || slugifyProject(title);
57
+ const relDir = `Projects/${slug}`;
58
+ const today = options.today ?? new Date().toISOString().slice(0, 10);
59
+ const repoPath = options.repoPath?.trim() ?? '';
60
+ const verify = options.verify?.trim() ?? 'npm test && npm run typecheck';
61
+ const defaultBranch = options.defaultBranch?.trim() ?? 'main';
62
+ const created = [];
63
+ const skipped = [];
64
+ const warnings = [];
65
+ const vars = {
66
+ DATE: today,
67
+ TITLE: title,
68
+ SLUG: slug,
69
+ REPO_PATH: repoPath,
70
+ VERIFY: verify,
71
+ DEFAULT_BRANCH: defaultBranch,
72
+ };
73
+ for (const name of WORKSPACE_FILES) {
74
+ const rel = `${relDir}/${name}`;
75
+ const path = join(brainPath, rel);
76
+ if ((await fileExists(path)) && !options.force) {
77
+ skipped.push(rel);
78
+ continue;
79
+ }
80
+ await mkdir(dirname(path), { recursive: true });
81
+ const raw = await readTemplate(name);
82
+ await writeFile(path, renderTemplate(raw, vars), 'utf8');
83
+ created.push(rel);
84
+ }
85
+ if (!created.length && skipped.length) {
86
+ return {
87
+ ok: false,
88
+ brainPath,
89
+ slug,
90
+ title,
91
+ relDir,
92
+ created,
93
+ skipped,
94
+ indexed: false,
95
+ warnings: ['Project workspace already exists. Re-run with --force to overwrite scaffold files.'],
96
+ };
97
+ }
98
+ const indexed = await maybeAppendProjectsIndex(brainPath, slug, title);
99
+ if (!indexed)
100
+ warnings.push('Projects/_Index.md was not updated (missing or link already present).');
101
+ return { ok: true, brainPath, slug, title, relDir, created, skipped, indexed, warnings };
102
+ }
103
+ export function formatScaffoldProjectReport(report) {
104
+ const lines = ['Sanook brain new project (workspace scaffold)'];
105
+ lines.push(`vault: ${report.brainPath}`);
106
+ lines.push(`slug: ${report.slug}`);
107
+ lines.push(`title: ${report.title}`);
108
+ lines.push(`dir: ${report.relDir}/`);
109
+ if (report.created.length) {
110
+ lines.push(`created (${report.created.length}):`);
111
+ for (const rel of report.created)
112
+ lines.push(` ${rel}`);
113
+ }
114
+ if (report.skipped.length) {
115
+ lines.push(`skipped (${report.skipped.length}):`);
116
+ for (const rel of report.skipped)
117
+ lines.push(` ${rel}`);
118
+ }
119
+ if (report.indexed)
120
+ lines.push('index: Projects/_Index.md updated');
121
+ for (const warning of report.warnings)
122
+ lines.push(`warning: ${warning}`);
123
+ return lines.join('\n');
124
+ }
@@ -0,0 +1,155 @@
1
+ import { BRAND } from './brand.js';
2
+ import { loadConfig } from './config.js';
3
+ import { gitContext } from './git.js';
4
+ import { SYSTEM } from './loop.js';
5
+ import { loadAutoMemory, loadBrainContext, loadMemory } from './memory.js';
6
+ import { personalityPrompt } from './personality.js';
7
+ import { loadRepoMap } from './repomap.js';
8
+ import { loadSkills, renderAvailableSkills } from './skills.js';
9
+ import { tools as builtInTools } from './tools/index.js';
10
+ const CHARS_PER_TOKEN = 4;
11
+ export function approximateTokens(chars) {
12
+ return chars <= 0 ? 0 : Math.ceil(chars / CHARS_PER_TOKEN);
13
+ }
14
+ function utf8Bytes(text) {
15
+ return Buffer.byteLength(text, 'utf8');
16
+ }
17
+ export function measurePromptSection(id, label, text) {
18
+ return {
19
+ id,
20
+ label,
21
+ chars: text.length,
22
+ bytes: utf8Bytes(text),
23
+ approxTokens: approximateTokens(text.length),
24
+ empty: text.length === 0,
25
+ };
26
+ }
27
+ function joinPromptBlocks(blocks) {
28
+ return blocks.filter(Boolean).join('\n\n');
29
+ }
30
+ function toJsonSafe(value, options = {}, depth = 0, seen = new WeakSet()) {
31
+ const maxDepth = options.maxDepth ?? 6;
32
+ const maxStringLength = options.maxStringLength ?? 2_000;
33
+ if (value == null)
34
+ return value;
35
+ if (typeof value === 'string')
36
+ return value.length > maxStringLength ? `${value.slice(0, maxStringLength)}...[truncated]` : value;
37
+ if (typeof value === 'number' || typeof value === 'boolean')
38
+ return value;
39
+ if (typeof value === 'bigint')
40
+ return value.toString();
41
+ if (typeof value === 'function' || typeof value === 'symbol' || typeof value === 'undefined')
42
+ return undefined;
43
+ if (depth >= maxDepth)
44
+ return '[MaxDepth]';
45
+ if (Array.isArray(value))
46
+ return value.map((item) => toJsonSafe(item, options, depth + 1, seen));
47
+ if (typeof value !== 'object')
48
+ return String(value);
49
+ if (seen.has(value))
50
+ return '[Circular]';
51
+ seen.add(value);
52
+ const out = {};
53
+ for (const [key, child] of Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) {
54
+ if (key === 'execute' || key === 'experimental_toToolResultContent')
55
+ continue;
56
+ const safe = toJsonSafe(child, options, depth + 1, seen);
57
+ if (safe !== undefined)
58
+ out[key] = safe;
59
+ }
60
+ seen.delete(value);
61
+ return out;
62
+ }
63
+ export function serializeToolSchemas(tools) {
64
+ const payload = Object.entries(tools)
65
+ .sort(([a], [b]) => a.localeCompare(b))
66
+ .map(([name, tool]) => {
67
+ const t = tool;
68
+ return {
69
+ name,
70
+ description: typeof t.description === 'string' ? t.description : '',
71
+ inputSchema: toJsonSafe(t.inputSchema ?? t.parameters),
72
+ };
73
+ });
74
+ return JSON.stringify(payload, null, 2);
75
+ }
76
+ export async function buildPromptSizeBreakdown(options = {}) {
77
+ const cwd = options.cwd ?? process.cwd();
78
+ const planMode = options.planMode ?? false;
79
+ const [config, memory, autoMemory, skills, git, brain, repoMap,] = await Promise.all([
80
+ (options.loadConfigImpl ?? loadConfig)({}, cwd),
81
+ (options.loadMemoryImpl ?? loadMemory)(cwd),
82
+ (options.loadAutoMemoryImpl ?? loadAutoMemory)(),
83
+ (options.loadSkillsImpl ?? loadSkills)(cwd),
84
+ (options.gitContextImpl ?? gitContext)(cwd),
85
+ (options.loadBrainContextImpl ?? loadBrainContext)(),
86
+ (options.loadRepoMapImpl ?? loadRepoMap)(cwd),
87
+ ]);
88
+ const planSuffix = planMode
89
+ ? '\n\nPLAN MODE: สำรวจและวางแผนเท่านั้น — ห้ามแก้ไฟล์หรือรันคำสั่งที่เปลี่ยน state. จบด้วยแผนเป็นขั้นตอนให้ user อนุมัติก่อนลงมือ.'
90
+ : '';
91
+ const brainNudge = brain
92
+ ? '\n- second-brain vault โหลดอยู่ (ดู <brain_vault>) — อ่าน current-state + โน้ตที่เกี่ยวก่อนงานไม่ trivial · เจอ preference/decision สำคัญ → remember (เข้า vault) · งานเสร็จควร route/บันทึกตาม Vault Structure Map ของ vault'
93
+ : '';
94
+ const baseSystem = SYSTEM + planSuffix + brainNudge;
95
+ const personality = personalityPrompt(config.personality);
96
+ const skillsBlock = renderAvailableSkills(skills);
97
+ const staticSystem = joinPromptBlocks([baseSystem, personality, autoMemory, skillsBlock, brain, memory, repoMap]);
98
+ const systemPromptText = joinPromptBlocks([staticSystem, git]);
99
+ const toolSchemaText = serializeToolSchemas(options.tools ?? builtInTools);
100
+ const sections = [
101
+ measurePromptSection('base-system', 'Base system', baseSystem),
102
+ measurePromptSection('personality', 'Personality overlay', personality),
103
+ measurePromptSection('auto-memory', 'Auto memory', autoMemory),
104
+ measurePromptSection('skills-index', 'Skills index', skillsBlock),
105
+ measurePromptSection('brain-context', 'Second-brain context', brain),
106
+ measurePromptSection('project-memory', 'Project memory', memory),
107
+ measurePromptSection('repo-map', 'Repo map', repoMap),
108
+ measurePromptSection('git-context', 'Git context', git),
109
+ ];
110
+ const systemPrompt = measurePromptSection('system-prompt', 'System prompt total', systemPromptText);
111
+ const toolSchemas = measurePromptSection('tool-schemas', 'Built-in tool schemas', toolSchemaText);
112
+ const totalText = `${systemPromptText}\n\n${toolSchemaText}`;
113
+ return {
114
+ cwd,
115
+ model: config.model,
116
+ planMode,
117
+ skillsCount: skills.length,
118
+ builtInToolsCount: Object.keys(options.tools ?? builtInTools).length,
119
+ sections,
120
+ systemPrompt,
121
+ toolSchemas,
122
+ total: measurePromptSection('total-fixed-payload', 'Total fixed payload', totalText),
123
+ notes: [
124
+ 'Counts are approximate; model tokenizers vary.',
125
+ 'MCP tools are intentionally not spawned here. Use `sanook mcp list --tools` for live MCP catalog details.',
126
+ 'The runtime sends git context as a separate system message so the static prompt cache stays useful.',
127
+ ],
128
+ };
129
+ }
130
+ function formatNumber(n) {
131
+ return new Intl.NumberFormat('en-US').format(n);
132
+ }
133
+ function formatSection(section) {
134
+ const empty = section.empty ? ' (empty)' : '';
135
+ return `${section.label.padEnd(22)} ${formatNumber(section.chars).padStart(8)} chars ~${formatNumber(section.approxTokens).padStart(6)} tokens ${formatNumber(section.bytes).padStart(8)} bytes${empty}`;
136
+ }
137
+ export function renderPromptSizeBreakdown(report) {
138
+ const lines = [
139
+ `${BRAND.productName} prompt-size`,
140
+ `cwd: ${report.cwd}`,
141
+ `model: ${report.model}${report.planMode ? ' plan-mode: on' : ''}`,
142
+ `skills: ${report.skillsCount} built-in tools: ${report.builtInToolsCount}`,
143
+ '',
144
+ formatSection(report.systemPrompt),
145
+ formatSection(report.toolSchemas),
146
+ formatSection(report.total),
147
+ '',
148
+ 'Breakdown:',
149
+ ...report.sections.map((section) => ` ${formatSection(section)}`),
150
+ '',
151
+ 'Notes:',
152
+ ...report.notes.map((note) => ` - ${note}`),
153
+ ];
154
+ return `${lines.join('\n')}\n`;
155
+ }
@@ -0,0 +1,138 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { codexHome } from './codex.js';
4
+ /** OpenAI Codex OAuth client id (same public client as Codex CLI / Hermes). */
5
+ export const CODEX_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
6
+ const CODEX_OAUTH_ISSUER = 'https://auth.openai.com';
7
+ const CODEX_OAUTH_TOKEN_URL = `${CODEX_OAUTH_ISSUER}/oauth/token`;
8
+ export const CODEX_DEVICE_VERIFY_URL = `${CODEX_OAUTH_ISSUER}/codex/device`;
9
+ function parseRetryAfterSeconds(headers) {
10
+ const raw = headers?.get('retry-after')?.trim();
11
+ if (!raw)
12
+ return undefined;
13
+ const seconds = Number(raw);
14
+ if (Number.isFinite(seconds) && seconds >= 0)
15
+ return Math.floor(seconds);
16
+ return undefined;
17
+ }
18
+ async function postJson(url, body, fetchImpl) {
19
+ return fetchImpl(url, {
20
+ method: 'POST',
21
+ headers: { 'Content-Type': 'application/json' },
22
+ body: JSON.stringify(body),
23
+ });
24
+ }
25
+ /** Step 1 — request device code (Hermes / Codex CLI compatible). */
26
+ export async function requestCodexDeviceCode(fetchImpl = fetch) {
27
+ const maxAttempts = 4;
28
+ let resp;
29
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
30
+ resp = await postJson(`${CODEX_OAUTH_ISSUER}/api/accounts/deviceauth/usercode`, { client_id: CODEX_OAUTH_CLIENT_ID }, fetchImpl);
31
+ if (resp.status !== 429)
32
+ break;
33
+ if (attempt < maxAttempts) {
34
+ const retryAfter = parseRetryAfterSeconds(resp.headers) ?? 2 ** attempt;
35
+ await new Promise((r) => setTimeout(r, Math.max(1000, Math.min(retryAfter * 1000, 60_000))));
36
+ }
37
+ }
38
+ if (!resp || resp.status === 429) {
39
+ throw new Error('OpenAI จำกัดการ login ชั่วคราว (429) — รอ 1 นาทีแล้วลองใหม่');
40
+ }
41
+ if (!resp.ok) {
42
+ throw new Error(`ขอ device code ไม่สำเร็จ (HTTP ${resp.status})`);
43
+ }
44
+ const data = (await resp.json());
45
+ const userCode = data.user_code?.trim();
46
+ const deviceAuthId = data.device_auth_id?.trim();
47
+ if (!userCode || !deviceAuthId)
48
+ throw new Error('OpenAI ตอบ device code ไม่ครบ');
49
+ const pollIntervalMs = Math.max(3000, Number(data.interval ?? 5) * 1000);
50
+ return { userCode, deviceAuthId, pollIntervalMs };
51
+ }
52
+ /** Step 2 — poll until the user completes browser login. */
53
+ export async function pollCodexDeviceCode(session, opts = {}) {
54
+ const fetchImpl = opts.fetchImpl ?? fetch;
55
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
56
+ const deadline = Date.now() + (opts.maxWaitMs ?? 15 * 60_000);
57
+ while (Date.now() < deadline) {
58
+ if (opts.signal?.aborted)
59
+ throw new Error('ยกเลิก login แล้ว');
60
+ await sleep(session.pollIntervalMs);
61
+ if (opts.signal?.aborted)
62
+ throw new Error('ยกเลิก login แล้ว');
63
+ const pollResp = await postJson(`${CODEX_OAUTH_ISSUER}/api/accounts/deviceauth/token`, { device_auth_id: session.deviceAuthId, user_code: session.userCode }, fetchImpl);
64
+ if (pollResp.status === 200) {
65
+ const payload = (await pollResp.json());
66
+ const authorization_code = payload.authorization_code?.trim();
67
+ const code_verifier = payload.code_verifier?.trim();
68
+ if (!authorization_code || !code_verifier)
69
+ throw new Error('OpenAI ตอบ authorization code ไม่ครบ');
70
+ return { authorization_code, code_verifier };
71
+ }
72
+ if (pollResp.status === 403 || pollResp.status === 404)
73
+ continue;
74
+ throw new Error(`รอ login ไม่สำเร็จ (HTTP ${pollResp.status})`);
75
+ }
76
+ throw new Error('หมดเวลารอ login (15 นาที) — ลองใหม่');
77
+ }
78
+ /** Step 3 — exchange authorization code for tokens. */
79
+ export async function exchangeCodexDeviceCode(exchange, fetchImpl = fetch) {
80
+ const body = new URLSearchParams({
81
+ grant_type: 'authorization_code',
82
+ code: exchange.authorization_code,
83
+ redirect_uri: `${CODEX_OAUTH_ISSUER}/deviceauth/callback`,
84
+ client_id: CODEX_OAUTH_CLIENT_ID,
85
+ code_verifier: exchange.code_verifier,
86
+ });
87
+ const resp = await fetchImpl(CODEX_OAUTH_TOKEN_URL, {
88
+ method: 'POST',
89
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
90
+ body,
91
+ });
92
+ if (resp.status === 429)
93
+ throw new Error('OpenAI จำกัดการ login ชั่วคราว (429) — รอแล้วลองใหม่');
94
+ if (!resp.ok)
95
+ throw new Error(`แลก token ไม่สำเร็จ (HTTP ${resp.status})`);
96
+ const tokens = (await resp.json());
97
+ const access_token = tokens.access_token?.trim();
98
+ const refresh_token = tokens.refresh_token?.trim();
99
+ if (!access_token || !refresh_token)
100
+ throw new Error('OpenAI ไม่ส่ง access/refresh token');
101
+ return {
102
+ access_token,
103
+ refresh_token,
104
+ id_token: tokens.id_token?.trim() || undefined,
105
+ };
106
+ }
107
+ /** Persist ChatGPT-plan credentials where the official Codex CLI expects them. */
108
+ export async function saveCodexAuthFile(tokens, home = codexHome()) {
109
+ await mkdir(home, { recursive: true, mode: 0o700 });
110
+ const authPath = join(home, 'auth.json');
111
+ const payload = {
112
+ auth_mode: 'chatgpt',
113
+ tokens: {
114
+ access_token: tokens.access_token,
115
+ refresh_token: tokens.refresh_token,
116
+ ...(tokens.id_token ? { id_token: tokens.id_token } : {}),
117
+ },
118
+ last_refresh: new Date().toISOString(),
119
+ };
120
+ await writeFile(authPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
121
+ return authPath;
122
+ }
123
+ /** Full Hermes-style device-code login → ~/.codex/auth.json (Codex CLI can reuse). */
124
+ export async function runCodexDeviceCodeLogin(opts = {}) {
125
+ const fetchImpl = opts.fetchImpl ?? fetch;
126
+ const onStatus = opts.onStatus ?? (() => { });
127
+ onStatus('requesting');
128
+ const session = await requestCodexDeviceCode(fetchImpl);
129
+ onStatus(`code:${session.userCode}`);
130
+ onStatus('waiting');
131
+ const exchange = await pollCodexDeviceCode(session, opts);
132
+ onStatus('exchanging');
133
+ const tokens = await exchangeCodexDeviceCode(exchange, fetchImpl);
134
+ onStatus('saving');
135
+ const authPath = await saveCodexAuthFile(tokens);
136
+ onStatus('done');
137
+ return authPath;
138
+ }
@@ -5,8 +5,18 @@ import { join } from 'node:path';
5
5
  export function codexHome() {
6
6
  return process.env.CODEX_HOME?.trim() || join(homedir(), '.codex');
7
7
  }
8
+ async function readCodexLoggedIn() {
9
+ try {
10
+ const auth = JSON.parse(await readFile(join(codexHome(), 'auth.json'), 'utf8'));
11
+ return auth?.auth_mode === 'chatgpt' || Boolean(auth?.tokens?.access_token);
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
8
17
  /** เช็กว่า codex CLI ติดตั้ง + login ChatGPT แล้ว */
9
18
  export async function detectCodex() {
19
+ const loggedIn = await readCodexLoggedIn();
10
20
  const hasBinary = await new Promise((resolve) => {
11
21
  const p = spawn('codex', ['--version'], { shell: process.platform === 'win32' });
12
22
  // timeout: binary ค้าง (shim รอ stdin / Gatekeeper stall ตอนรันครั้งแรกบน macOS) → ไม่ให้ wizard ตัน
@@ -24,16 +34,18 @@ export async function detectCodex() {
24
34
  });
25
35
  });
26
36
  if (!hasBinary) {
27
- return { installed: false, loggedIn: false, reason: 'ไม่พบ codex CLI — ติดตั้ง: npm i -g @openai/codex' };
28
- }
29
- try {
30
- const auth = JSON.parse(await readFile(join(codexHome(), 'auth.json'), 'utf8'));
31
- const loggedIn = auth?.auth_mode === 'chatgpt' || Boolean(auth?.tokens?.access_token);
32
- return { installed: true, loggedIn, reason: loggedIn ? undefined : 'ยังไม่ได้ login — รัน: codex login' };
37
+ return {
38
+ installed: false,
39
+ loggedIn,
40
+ reason: loggedIn
41
+ ? 'login แล้ว แต่ยังไม่มี codex CLI ติดตั้ง: npm i -g @openai/codex'
42
+ : 'ไม่พบ codex CLI ติดตั้ง: npm i -g @openai/codex',
43
+ };
33
44
  }
34
- catch {
35
- return { installed: true, loggedIn: false, reason: 'ยังไม่ได้ login — รัน: codex login' };
45
+ if (loggedIn) {
46
+ return { installed: true, loggedIn: true, reason: undefined };
36
47
  }
48
+ return { installed: true, loggedIn: false, reason: 'ยังไม่ได้ login — รัน: codex login' };
37
49
  }
38
50
  /**
39
51
  * รัน `codex exec` แบบ non-interactive — ส่ง prompt ทาง stdin, parse JSONL events
@@ -36,3 +36,24 @@ export function assertDirectApiKey(policy, key) {
36
36
  export function redactKey(s) {
37
37
  return s.replace(/\b(AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9_-]{6,}|AIza[A-Za-z0-9_-]{10,}|xai-[A-Za-z0-9]{10,}|gsk_[A-Za-z0-9]{10,}|[A-Za-z0-9_-]{24,})\b/g, (m) => (m.length > 8 ? `${m.slice(0, 4)}…${m.slice(-2)}` : '…'));
38
38
  }
39
+ export function redactUnknown(value) {
40
+ const visiting = new WeakSet();
41
+ const visit = (current) => {
42
+ if (typeof current === 'string')
43
+ return redactKey(current);
44
+ if (!current || typeof current !== 'object')
45
+ return current;
46
+ if (visiting.has(current))
47
+ return '[Circular]';
48
+ visiting.add(current);
49
+ try {
50
+ if (Array.isArray(current))
51
+ return current.map(visit);
52
+ return Object.fromEntries(Object.entries(current).map(([k, v]) => [redactKey(k), visit(v)]));
53
+ }
54
+ finally {
55
+ visiting.delete(current);
56
+ }
57
+ };
58
+ return visit(value);
59
+ }
@@ -22,7 +22,7 @@ export async function listRemoteModels(cfg, key, timeoutMs = 6000) {
22
22
  .map((m) => (m.name ?? '').replace(/^models\//, ''))
23
23
  .filter(Boolean);
24
24
  }
25
- const base = process.env[`${cfg.id.toUpperCase()}_BASE_URL`] ?? cfg.baseURL;
25
+ const base = process.env[`${cfg.id.toUpperCase()}_BASE_URL`]?.trim() || cfg.baseURL?.trim();
26
26
  if (!base)
27
27
  return []; // ไม่มี baseURL = ดึงไม่ได้
28
28
  const headers = cfg.id === 'anthropic'
@@ -19,7 +19,9 @@ function inlineSourceValue(value) {
19
19
  export function parseSearchArgs(args) {
20
20
  const queryParts = [];
21
21
  let mode = 'auto';
22
+ let modeSet = false;
22
23
  let limit = 8;
24
+ let limitSet = false;
23
25
  let sources;
24
26
  for (let i = 0; i < args.length; i++) {
25
27
  const a = args[i];
@@ -36,7 +38,10 @@ export function parseSearchArgs(args) {
36
38
  return { ok: false, message: `--mode ต้องระบุค่าเป็น ${SEARCH_MODES.join('|')}` };
37
39
  if (!isSearchMode(v))
38
40
  return { ok: false, message: `--mode ต้องเป็น ${SEARCH_MODES.join('|')}` };
41
+ if (modeSet)
42
+ return { ok: false, message: 'ใช้ --mode เพียงครั้งเดียว' };
39
43
  mode = v;
44
+ modeSet = true;
40
45
  }
41
46
  else if (a === '--limit' || a.startsWith('--limit=')) {
42
47
  const next = a === '--limit' ? takeValue(args, i) : undefined;
@@ -48,7 +53,10 @@ export function parseSearchArgs(args) {
48
53
  const n = parsePositiveInteger(raw);
49
54
  if (n === undefined)
50
55
  return { ok: false, message: '--limit ต้องเป็น integer บวก เช่น 8' };
56
+ if (limitSet)
57
+ return { ok: false, message: 'ใช้ --limit เพียงครั้งเดียว' };
51
58
  limit = n;
59
+ limitSet = true;
52
60
  }
53
61
  else if (a === '--source' || a === '--sources' || a.startsWith('--source=') || a.startsWith('--sources=')) {
54
62
  const next = a === '--source' || a === '--sources' ? takeValue(args, i) : undefined;
@@ -62,7 +70,7 @@ export function parseSearchArgs(args) {
62
70
  }
63
71
  if (bad.length)
64
72
  return { ok: false, message: `--source ต้องเป็น ${SEARCH_SOURCES.join(',')} (คั่นหลายค่าได้ด้วย comma)` };
65
- sources = [...new Set(requested)];
73
+ sources = [...new Set([...(sources ?? []), ...requested])];
66
74
  }
67
75
  else {
68
76
  queryParts.push(a);
@@ -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,6 +24,7 @@ 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';
28
29
  import { buildVectorIndex, embedTexts, getEmbedder, invalidateVectors, saveVectors } from './embed-store.js';
29
30
  /** strip a .md path to a human title fallback when a chunk has no heading. */
@@ -38,6 +39,14 @@ export async function indexVaultFiles(index, manifest, fs) {
38
39
  const next = {};
39
40
  const diff = { added: 0, updated: 0, removed: 0, skipped: 0 };
40
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
+ }
41
50
  const seenExisting = new Set();
42
51
  for (const rel of paths) {
43
52
  const fp = await fs.fingerprint(rel);
@@ -192,15 +201,6 @@ export function nodeVaultFS(root) {
192
201
  };
193
202
  }
194
203
  const SESSIONS_DIR = appHomePath('sessions');
195
- async function configEmbeddingModel() {
196
- try {
197
- const cfg = JSON.parse(await readFile(appHomePath('config.json'), 'utf8'));
198
- return cfg.embeddingModel;
199
- }
200
- catch {
201
- return undefined;
202
- }
203
- }
204
204
  /** load first-user-message of the most recent sessions (bounded) for the session corpus. */
205
205
  export async function loadRecentSessions(limit = 60) {
206
206
  const out = [];
@@ -263,7 +263,7 @@ export async function reindex(now = Date.now()) {
263
263
  })));
264
264
  await saveIndex(index, nextManifest);
265
265
  let vectors = 0;
266
- const embedder = getEmbedder(process.env.SANOOK_EMBEDDING_MODEL ?? (await configEmbeddingModel()));
266
+ const embedder = getEmbedder(await embeddingModelSpec());
267
267
  if (!embedder) {
268
268
  await invalidateVectors().catch(() => { });
269
269
  }