sanook-cli 0.5.5 → 0.5.8

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.
@@ -0,0 +1,214 @@
1
+ import { runAgent } from '../loop.js';
2
+ import { loadConfig } from '../config.js';
3
+ import { redactKey } from '../providers/keys.js';
4
+ import { describeToolCall } from '../ui/tool-activity.js';
5
+ const HISTORY = new Map(); // sessionId → conversation (localhost single-user)
6
+ const MAX_HISTORY_SESSIONS = 20;
7
+ function sseSend(res, event) {
8
+ if (res.destroyed || res.writableEnded)
9
+ return; // client gone — don't write to a dead socket
10
+ try {
11
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
12
+ }
13
+ catch {
14
+ /* socket closed mid-write */
15
+ }
16
+ }
17
+ async function readJsonBody(req) {
18
+ const chunks = [];
19
+ for await (const chunk of req)
20
+ chunks.push(Buffer.from(chunk));
21
+ const raw = Buffer.concat(chunks).toString('utf8');
22
+ try {
23
+ const parsed = JSON.parse(raw || '{}');
24
+ return parsed && typeof parsed === 'object' ? parsed : {};
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ }
30
+ /** POST /api/terminal/run — body {prompt, sessionId, autoApprove?} → SSE stream of agent events */
31
+ export async function handleTerminalRun(req, res) {
32
+ const body = await readJsonBody(req);
33
+ const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '';
34
+ const sessionId = typeof body.sessionId === 'string' && body.sessionId ? body.sessionId : 'web';
35
+ const autoApprove = body.autoApprove !== false; // default true (localhost = same trust as running CLI)
36
+ res.writeHead(200, {
37
+ 'content-type': 'text/event-stream; charset=utf-8',
38
+ 'cache-control': 'no-cache, no-transform',
39
+ connection: 'keep-alive',
40
+ });
41
+ if (!prompt) {
42
+ sseSend(res, { type: 'error', message: 'empty prompt' });
43
+ sseSend(res, { type: 'done' });
44
+ res.end();
45
+ return;
46
+ }
47
+ const rememberedFacts = [];
48
+ // abort the agent run if the browser disconnects mid-stream — otherwise it keeps executing
49
+ // (and metering cost) and writing to a closed socket. RunAgent honors opts.signal.
50
+ const ac = new AbortController();
51
+ const onClose = () => ac.abort();
52
+ res.on('close', onClose);
53
+ // headers are already sent above, so everything that can throw stays inside this try and emits an
54
+ // SSE error here — otherwise the throw propagates to the server's catch, which calls writeHead(500)
55
+ // on an already-headed response (ERR_HTTP_HEADERS_SENT).
56
+ try {
57
+ const config = await loadConfig({});
58
+ const model = config.model;
59
+ const history = HISTORY.get(sessionId) ?? [];
60
+ sseSend(res, { type: 'status', detail: `Agent · ${model}` });
61
+ const { messages } = await runAgent({
62
+ model,
63
+ prompt,
64
+ history,
65
+ maxSteps: 20,
66
+ permissionMode: autoApprove ? 'auto' : 'ask',
67
+ signal: ac.signal,
68
+ usageMeta: { sessionId: `web:${sessionId}`, source: 'repl' },
69
+ onEvent: (e) => {
70
+ switch (e.type) {
71
+ case 'text':
72
+ if (e.text)
73
+ sseSend(res, { type: 'text', text: e.text });
74
+ break;
75
+ case 'reasoning':
76
+ if (e.text)
77
+ sseSend(res, { type: 'reasoning', text: e.text });
78
+ break;
79
+ case 'tool-call': {
80
+ if (e.tool === 'remember') {
81
+ const fact = e.detail?.fact;
82
+ if (typeof fact === 'string' && fact.trim())
83
+ rememberedFacts.push(fact.trim());
84
+ }
85
+ const activity = describeToolCall(e.tool ?? 'tool', e.detail);
86
+ sseSend(res, { type: 'tool-call', tool: e.tool, title: activity.title, diff: activity.diff ?? null });
87
+ break;
88
+ }
89
+ case 'tool-result':
90
+ sseSend(res, { type: 'tool-result', tool: e.tool });
91
+ break;
92
+ case 'status':
93
+ if (typeof e.detail === 'string')
94
+ sseSend(res, { type: 'status', detail: e.detail });
95
+ break;
96
+ case 'error':
97
+ sseSend(res, { type: 'error', message: redactKey(String(e.detail ?? e.text ?? 'error')) });
98
+ break;
99
+ }
100
+ },
101
+ });
102
+ HISTORY.set(sessionId, messages);
103
+ if (HISTORY.size > MAX_HISTORY_SESSIONS)
104
+ HISTORY.delete([...HISTORY.keys()][0]);
105
+ for (const fact of rememberedFacts)
106
+ sseSend(res, { type: 'memory', fact });
107
+ // ✨ self-improvement (same path as REPL/headless)
108
+ try {
109
+ const { maybeAutoSkill } = await import('../self-improve.js');
110
+ const { defaultSkillSynthesizer } = await import('../self-improve-synth.js');
111
+ const { loadSkills, saveSkill } = await import('../skills.js');
112
+ const existing = new Set((await loadSkills()).map((s) => s.name));
113
+ const auto = await maybeAutoSkill(prompt, { synthesize: defaultSkillSynthesizer(model), saveSkill, existingSkillNames: existing });
114
+ if (auto.created && auto.skillName)
115
+ sseSend(res, { type: 'skill', name: auto.skillName, count: auto.count });
116
+ }
117
+ catch {
118
+ /* best-effort */
119
+ }
120
+ }
121
+ catch (err) {
122
+ sseSend(res, { type: 'error', message: redactKey(err.message) });
123
+ }
124
+ finally {
125
+ res.off('close', onClose);
126
+ }
127
+ sseSend(res, { type: 'done' });
128
+ if (!res.writableEnded)
129
+ res.end();
130
+ }
131
+ export function resetTerminalSession(sessionId) {
132
+ HISTORY.delete(sessionId);
133
+ }
134
+ // ---- Raw shell (optional node-pty + ws) ------------------------------------
135
+ let shellAvailability = null;
136
+ // computed specifiers so TS/bundler don't statically resolve these optional, possibly-absent deps
137
+ const PTY_MODULE = 'node-pty';
138
+ const WS_MODULE = 'ws';
139
+ export async function shellStatus() {
140
+ if (shellAvailability)
141
+ return shellAvailability;
142
+ const missing = [];
143
+ try {
144
+ await import(PTY_MODULE);
145
+ }
146
+ catch {
147
+ missing.push('node-pty');
148
+ }
149
+ try {
150
+ await import(WS_MODULE);
151
+ }
152
+ catch {
153
+ missing.push('ws');
154
+ }
155
+ shellAvailability = missing.length
156
+ ? { available: false, reason: `ติดตั้ง dependency เสริมก่อน: npm i ${missing.join(' ')}` }
157
+ : { available: true, reason: 'ready' };
158
+ return shellAvailability;
159
+ }
160
+ /** Attach a ws upgrade handler for the raw shell at /api/terminal/shell. No-op if deps missing. */
161
+ export async function attachShell(server) {
162
+ const status = await shellStatus();
163
+ if (!status.available)
164
+ return;
165
+ // dynamic import keeps node-pty/ws optional at build/runtime
166
+ const pty = (await import(PTY_MODULE));
167
+ const wsmod = (await import(WS_MODULE));
168
+ const wss = new wsmod.WebSocketServer({ noServer: true });
169
+ server.on('upgrade', (req, socket, head) => {
170
+ const url = new URL(req.url ?? '/', 'http://local');
171
+ if (url.pathname !== '/api/terminal/shell') {
172
+ // not ours — destroy the half-open upgraded socket so it doesn't leak (this is the only
173
+ // 'upgrade' handler on the dashboard server; with noServer ws, Node won't auto-close it).
174
+ socket.destroy();
175
+ return;
176
+ }
177
+ wss.handleUpgrade(req, socket, head, (ws) => {
178
+ const shell = process.platform === 'win32' ? 'powershell.exe' : process.env.SHELL || 'bash';
179
+ const term = pty.spawn(shell, [], {
180
+ name: 'xterm-color',
181
+ cols: 80,
182
+ rows: 24,
183
+ cwd: process.env.HOME || process.cwd(),
184
+ env: process.env,
185
+ });
186
+ const safeKill = () => {
187
+ try {
188
+ term.kill();
189
+ }
190
+ catch {
191
+ /* pty already exited — kill() can throw for a reaped pid on some platforms */
192
+ }
193
+ };
194
+ term.onData((data) => ws.send(JSON.stringify({ type: 'data', data })));
195
+ term.onExit(() => ws.close());
196
+ ws.on('message', (raw) => {
197
+ try {
198
+ const msg = JSON.parse(String(raw));
199
+ if (msg.type === 'data' && typeof msg.data === 'string')
200
+ term.write(msg.data);
201
+ else if (msg.type === 'resize' && msg.cols && msg.rows)
202
+ term.resize(msg.cols, msg.rows);
203
+ }
204
+ catch {
205
+ /* ignore malformed frame */
206
+ }
207
+ });
208
+ ws.on('close', safeKill);
209
+ // without an 'error' listener, a ws error (abrupt TCP reset / protocol violation) is rethrown
210
+ // by Node's EventEmitter as an uncaught exception and crashes the whole dashboard server.
211
+ ws.on('error', safeKill);
212
+ });
213
+ });
214
+ }
package/dist/diff.js CHANGED
@@ -1,7 +1,11 @@
1
1
  // minimal unified-ish diff (zero dep) — โชว์ให้เห็นว่าแก้อะไรก่อน/หลัง โปร่งใส
2
2
  const MAX_LINES = 14;
3
- /** diff ของ edit (old block → new block) — render เป็น -old / +new */
4
- export function renderEditDiff(oldStr, newStr) {
3
+ /**
4
+ * core prefix/suffix-trim diff — shared by renderEditDiff (string) and the REPL's colored
5
+ * diffLines (src/ui/tool-activity.ts) so the algorithm lives in one place. Callers pick their
6
+ * own line cap; only the formatting differs.
7
+ */
8
+ export function editDiffSegments(oldStr, newStr, max = MAX_LINES) {
5
9
  const oldL = oldStr.split('\n');
6
10
  const newL = newStr.split('\n');
7
11
  // ตัด common prefix/suffix lines ที่เหมือนกัน เพื่อโชว์เฉพาะส่วนที่เปลี่ยน
@@ -15,15 +19,25 @@ export function renderEditDiff(oldStr, newStr) {
15
19
  suf++;
16
20
  const oldMid = oldL.slice(pre, oldL.length - suf);
17
21
  const newMid = newL.slice(pre, newL.length - suf);
22
+ return {
23
+ removed: oldMid.slice(0, max),
24
+ added: newMid.slice(0, max),
25
+ moreRemoved: Math.max(0, oldMid.length - max),
26
+ moreAdded: Math.max(0, newMid.length - max),
27
+ };
28
+ }
29
+ /** diff ของ edit (old block → new block) — render เป็น -old / +new */
30
+ export function renderEditDiff(oldStr, newStr) {
31
+ const seg = editDiffSegments(oldStr, newStr, MAX_LINES);
18
32
  const lines = [];
19
- for (const l of oldMid.slice(0, MAX_LINES))
33
+ for (const l of seg.removed)
20
34
  lines.push(`- ${l}`);
21
- if (oldMid.length > MAX_LINES)
22
- lines.push(` …(-${oldMid.length - MAX_LINES} บรรทัด)`);
23
- for (const l of newMid.slice(0, MAX_LINES))
35
+ if (seg.moreRemoved)
36
+ lines.push(` …(-${seg.moreRemoved} บรรทัด)`);
37
+ for (const l of seg.added)
24
38
  lines.push(`+ ${l}`);
25
- if (newMid.length > MAX_LINES)
26
- lines.push(` …(+${newMid.length - MAX_LINES} บรรทัด)`);
39
+ if (seg.moreAdded)
40
+ lines.push(` …(+${seg.moreAdded} บรรทัด)`);
27
41
  return lines.join('\n');
28
42
  }
29
43
  /** สรุปการ write — จำนวนบรรทัด/ตัวอักษร + ถ้าเขียนทับ บอก before→after */
@@ -172,6 +172,10 @@ async function runAndSaveGatewayTurn(opts, existing, prompt, history, model) {
172
172
  maxSteps: opts.maxSteps ?? 20,
173
173
  budgetUsd: opts.budgetUsd,
174
174
  permissionMode: opts.permissionMode ?? 'ask',
175
+ usageMeta: {
176
+ sessionId: `${opts.platform}:${opts.target}`,
177
+ source: 'gateway',
178
+ },
175
179
  });
176
180
  await saveGatewayState(opts, existing, model, messages);
177
181
  return { text, messages, suppressDelivery: shouldSuppressDelivery(text) };
@@ -0,0 +1,91 @@
1
+ // Multi-platform install metadata — single source for Dashboard, README sync, and docs.
2
+ import { readFileSync } from 'node:fs';
3
+ import { join, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ export const INSTALL_PKG = 'sanook-cli';
6
+ export const INSTALL_REPO = 'Sir-chawakorn/sanook-cli';
7
+ export const INSTALL_REPO_URL = `https://github.com/${INSTALL_REPO}`;
8
+ export const INSTALL_BRANCH = 'main';
9
+ /** Custom domain when DNS + Pages are configured */
10
+ export const INSTALL_DOMAIN = 'sanook.ai';
11
+ /** GitHub Pages project URL (works when gh-pages branch is deployed) */
12
+ export const INSTALL_PAGES_URL = `https://${INSTALL_REPO.split('/')[0]}.github.io/${INSTALL_REPO.split('/')[1]}`;
13
+ /** jsDelivr CDN — stable short URL without custom domain */
14
+ export const INSTALL_CDN_URL = `https://cdn.jsdelivr.net/gh/${INSTALL_REPO}@${INSTALL_BRANCH}/scripts`;
15
+ const __dir = dirname(fileURLToPath(import.meta.url));
16
+ /** package.json version at build time (repo root when running from src/) */
17
+ export function installPkgVersion() {
18
+ try {
19
+ const pkg = JSON.parse(readFileSync(join(__dir, '..', 'package.json'), 'utf8'));
20
+ return pkg.version ?? 'latest';
21
+ }
22
+ catch {
23
+ return 'latest';
24
+ }
25
+ }
26
+ export function installScriptUrl(name, preferDomain = false) {
27
+ if (preferDomain)
28
+ return `https://${INSTALL_DOMAIN}/${name}`;
29
+ return `https://raw.githubusercontent.com/${INSTALL_REPO}/${INSTALL_BRANCH}/scripts/${name}`;
30
+ }
31
+ export function installScriptPagesUrl(name) {
32
+ return `${INSTALL_PAGES_URL}/${name}`;
33
+ }
34
+ export function installScriptCdnUrl(name) {
35
+ return `${INSTALL_CDN_URL}/${name}`;
36
+ }
37
+ /** Install channels shown on Dashboard + docs. `ready` = users can run today without extra infra. */
38
+ export function installMethods() {
39
+ const sh = installScriptUrl('install.sh');
40
+ const ps1 = installScriptUrl('install.ps1');
41
+ const shPages = installScriptPagesUrl('install.sh');
42
+ const shDomain = installScriptUrl('install.sh', true);
43
+ const shCdn = installScriptCdnUrl('install.sh');
44
+ return [
45
+ {
46
+ id: 'npm',
47
+ label: 'npm / npx',
48
+ recommended: true,
49
+ ready: true,
50
+ commands: [
51
+ { os: 'macOS / Linux / Windows', cmd: `npm install -g ${INSTALL_PKG}` },
52
+ { os: 'Run without installing', cmd: `npx ${INSTALL_PKG}` },
53
+ ],
54
+ note: 'ต้องมี Node.js ≥ 22',
55
+ },
56
+ {
57
+ id: 'curl',
58
+ label: 'Install script',
59
+ ready: true,
60
+ commands: [
61
+ { os: 'macOS / Linux / WSL (GitHub raw)', cmd: `curl -fsSL ${sh} | bash` },
62
+ { os: 'Windows PowerShell (GitHub raw)', cmd: `irm ${ps1} | iex` },
63
+ { os: 'CDN (jsDelivr)', cmd: `curl -fsSL ${shCdn} | bash` },
64
+ { os: 'GitHub Pages', cmd: `curl -fsSL ${shPages} | bash` },
65
+ { os: `Short URL (${INSTALL_DOMAIN})`, cmd: `curl -fsSL ${shDomain} | bash` },
66
+ ],
67
+ note: `${INSTALL_DOMAIN} ต้องตั้ง DNS ที่ GoDaddy ก่อน — ดู scripts/configure-sanook-ai-dns.sh`,
68
+ },
69
+ {
70
+ id: 'homebrew',
71
+ label: 'Homebrew',
72
+ ready: true,
73
+ commands: [
74
+ { os: 'macOS / Linux (trust tap ครั้งแรก)', cmd: `brew trust ${INSTALL_REPO.split('/')[0]}/tap` },
75
+ { os: 'macOS / Linux', cmd: `brew tap ${INSTALL_REPO.split('/')[0]}/tap` },
76
+ { os: 'macOS / Linux', cmd: `brew install ${INSTALL_PKG}` },
77
+ ],
78
+ note: `Live ที่ homebrew-tap — brew tap ${INSTALL_REPO.split('/')[0]}/tap && brew install ${INSTALL_PKG}`,
79
+ },
80
+ {
81
+ id: 'winget',
82
+ label: 'WinGet',
83
+ ready: false,
84
+ commands: [{ os: 'Windows', cmd: 'winget install Sanook.SanookCLI' }],
85
+ note: 'CLA ลงนามแล้ว — PR #391114 รอ validation + merge (release zip v0.5.7 พร้อม)',
86
+ },
87
+ ];
88
+ }
89
+ export function dashboardInstallPayload() {
90
+ return { pkg: INSTALL_PKG, version: installPkgVersion(), methods: installMethods() };
91
+ }
package/dist/loop.js CHANGED
@@ -19,6 +19,7 @@ import { agentTuning, loadConfig } from './config.js';
19
19
  import { BRAND, envFlag } from './brand.js';
20
20
  import { semanticRecallHits } from './knowledge.js';
21
21
  import { personalityPrompt } from './personality.js';
22
+ import { recordAgentUsage, usageFromCodexPayload } from './usage-ledger.js';
22
23
  // auto-compact เมื่อ context ใกล้เต็ม — conservative (safe สำหรับ model 200K, เผื่อ output)
23
24
  const AUTO_COMPACT_TOKENS = 120_000;
24
25
  const OS_LABEL = process.platform === 'win32'
@@ -148,7 +149,6 @@ async function maybeWrapWithHeadroom(model) {
148
149
  * แกน harness — agent loop: LLM -> tool -> result -> loop จนเสร็จ
149
150
  * multi-provider (BYOK) ผ่าน registry + cost meter + budget cap
150
151
  */
151
- /** delegate path — spawn official codex CLI (ChatGPT plan quota) แทน SDK loop */
152
152
  async function runDelegate(opts) {
153
153
  const { runCodex } = await import('./providers/codex.js');
154
154
  const meter = new CostMeter(specKey(opts.model), opts.budgetUsd, agentContext.getStore()?.sharedBudget);
@@ -173,10 +173,11 @@ async function runDelegate(opts) {
173
173
  // sandbox: plan/ask-mode → read-only (สกัด approval รายไฟล์ของ codex ไม่ได้ จึงไม่ให้แก้);
174
174
  // auto (--yes / config auto) → workspace-write เพื่อให้ codex แก้ไฟล์ได้จริง (ไม่งั้นเป็น coding agent ที่แก้อะไรไม่ได้)
175
175
  const sandbox = opts.planMode || (opts.permissionMode ?? 'ask') === 'ask' ? 'read-only' : 'workspace-write';
176
+ opts.onEvent?.({ type: 'status', detail: `Codex · ${model} · ${sandbox}` });
176
177
  let text = '';
177
178
  const out = await runCodex({
178
179
  prompt,
179
- model: model === 'gpt-5-codex' ? undefined : model,
180
+ model: model === PROVIDERS.codex.models.default ? undefined : model,
180
181
  sandbox,
181
182
  cwd: opts.cwd, // worktree isolation ของ sub-agent
182
183
  signal: opts.signal,
@@ -190,6 +191,9 @@ async function runDelegate(opts) {
190
191
  opts.onEvent?.({ type: 'text', text: delta });
191
192
  }
192
193
  else if (e.type === 'usage') {
194
+ const parsed = usageFromCodexPayload(e.usage);
195
+ if (parsed)
196
+ meter.add(parsed);
193
197
  opts.onEvent?.({ type: 'finish', detail: 'codex · ChatGPT quota' });
194
198
  }
195
199
  },
@@ -200,7 +204,26 @@ async function runDelegate(opts) {
200
204
  { role: 'user', content: opts.prompt },
201
205
  { role: 'assistant', content: text },
202
206
  ];
203
- return { messages, text, cost: meter };
207
+ return finishAgentRun(opts, { messages, text, cost: meter });
208
+ }
209
+ function inferUsageSource(opts) {
210
+ if (opts.usageMeta?.source)
211
+ return opts.usageMeta.source;
212
+ if ((opts.subagentDepth ?? 0) > 0)
213
+ return 'subagent';
214
+ if (opts.planMode)
215
+ return 'plan';
216
+ return 'headless';
217
+ }
218
+ function finishAgentRun(opts, result) {
219
+ recordAgentUsage({
220
+ model: opts.model,
221
+ cost: result.cost,
222
+ cwd: opts.cwd ?? agentCwd(),
223
+ sessionId: opts.usageMeta?.sessionId,
224
+ source: inferUsageSource(opts),
225
+ });
226
+ return result;
204
227
  }
205
228
  export async function runAgent(opts) {
206
229
  // context ผ่าน AsyncLocalStorage (ไม่ใช่ process.env global) → parallel sub-agent ไม่ชนกัน
@@ -213,6 +236,7 @@ export async function runAgent(opts) {
213
236
  if (PROVIDERS[parseSpec(opts.model).provider]?.kind === 'delegate') {
214
237
  return runDelegate(opts);
215
238
  }
239
+ opts.onEvent?.({ type: 'status', detail: `Agent · ${opts.model}` });
216
240
  const rawModel = resolveModel(opts.model); // throws ถ้าไม่มี key / provider ผิด
217
241
  let meter = new CostMeter(specKey(opts.model), opts.budgetUsd, sharedBudget);
218
242
  // โหลด context: auto-memory + skills + git state + repo map + project SANOOK.md → system prompt
@@ -355,14 +379,17 @@ export async function runAgent(opts) {
355
379
  opts.onEvent?.({ type: 'text', text: part.text });
356
380
  break;
357
381
  case 'reasoning-delta':
382
+ opts.onEvent?.({ type: 'status', detail: 'Thinking…' });
358
383
  opts.onEvent?.({ type: 'reasoning', text: part.text });
359
384
  break;
360
385
  case 'tool-call':
361
386
  if (isMutatingTool(part.toolName))
362
387
  sideEffectToolSeen = true;
388
+ opts.onEvent?.({ type: 'status', detail: `Tool · ${part.toolName}` });
363
389
  opts.onEvent?.({ type: 'tool-call', tool: part.toolName, detail: part.input });
364
390
  break;
365
391
  case 'tool-result':
392
+ opts.onEvent?.({ type: 'status', detail: `Done · ${part.toolName}` });
366
393
  opts.onEvent?.({ type: 'tool-result', tool: part.toolName, detail: part.output });
367
394
  break;
368
395
  case 'error':
@@ -409,5 +436,5 @@ export async function runAgent(opts) {
409
436
  throw new Error(cleanProviderError(streamError));
410
437
  const response = await result.response;
411
438
  // คืน history เต็ม (conversation + response messages) — ไม่รวม system (กัน user turn เก่าหาย + ไม่ save system ซ้ำ)
412
- return { messages: [...conversation, ...response.messages], text, cost: meter };
439
+ return finishAgentRun(opts, { messages: [...conversation, ...response.messages], text, cost: meter });
413
440
  }