sanook-cli 0.5.5 → 0.5.7

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.7
4
+
5
+ ### Local token usage ledger (ccusage-style)
6
+
7
+ - **Persistent usage tracking** — every agent turn appends to `~/.sanook/usage/events.jsonl` with input/output/cache tokens, estimated cost, model, session id, and source (`repl`, `headless`, `gateway`, `subagent`, `plan`).
8
+ - **`sanook usage`** — daily / weekly / monthly / session reports with ASCII tables and `--json` export (similar to [ccusage](https://ccusage.com/guide/getting-started)).
9
+ - **Codex usage capture** — parse `turn.completed` token counts from Codex JSONL into the ledger.
10
+ - **`/cost` / `/usage`** in REPL now points to `sanook usage daily` for full history.
11
+ - Disable with `SANOOK_DISABLE_USAGE=1`.
12
+
13
+ ## 0.5.6
14
+
15
+ ### Install UX, second-brain wiring, session save, terminal visibility, Codex models
16
+
17
+ - **`sanookai` CLI alias** — backward-compatible binary name alongside `sanook` (fixes Windows "sanookai not recognized" when docs/videos use the old name).
18
+ - **Postinstall hints** — clarify global vs local install; suggest `npx sanook` / `npx sanookai` when not on PATH.
19
+ - **Second-brain project link** — after setup brain wizard, auto-scaffold `Projects/<slug>/` for the current repo and create/link `SANOOK.md` project memory.
20
+ - **Session save on exit** — Ctrl+C (empty prompt) or `/quit` saves the REPL session and writes a summarized note to `Sessions/` in the vault.
21
+ - **Live agent status** — REPL shows Codex/Agent/Tool/Thinking status while work is in progress.
22
+ - **Codex model picker** — delegate provider lists current Codex models (`gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, legacy codex variants, spark).
23
+
3
24
  ## 0.5.5
4
25
 
5
26
  ### Hermes-style setup, dashboard, and terminal parity
package/dist/bin.js CHANGED
@@ -132,6 +132,7 @@ usage:
132
132
  ${BRAND.cliName} --json "<task>" headless, JSONL output (for CI/scripts)
133
133
  ${BRAND.cliName} sessions list/resume-audit saved conversation sessions
134
134
  ${BRAND.cliName} insights local usage/session insights
135
+ ${BRAND.cliName} usage [daily|...] token/cost ledger (ccusage-style)
135
136
  ${BRAND.cliName} dump [--show-keys] support snapshot (secrets redacted)
136
137
  ${BRAND.cliName} prompt-size [--json] inspect prompt/context budget without calling a model
137
138
  ${BRAND.cliName} runtimes [--json] inspect optional Python/Rust runtime surface
@@ -2317,6 +2318,17 @@ async function runSessions(args) {
2317
2318
  console.error(`ไม่รู้จัก: sessions ${action}\n${sessionUsage()}`);
2318
2319
  process.exit(1);
2319
2320
  }
2321
+ async function runUsage(args) {
2322
+ const { parseUsageArgs, renderUsageReport, usageHelpText } = await import('./usage-cli.js');
2323
+ const parsed = parseUsageArgs(args);
2324
+ if (!parsed) {
2325
+ console.log(usageHelpText());
2326
+ if (args.length && !args.includes('-h') && !args.includes('--help'))
2327
+ process.exit(2);
2328
+ return;
2329
+ }
2330
+ console.log(await renderUsageReport(parsed));
2331
+ }
2320
2332
  async function runInsights(args) {
2321
2333
  const { parseInsightsArgs } = await import('./insights-args.js');
2322
2334
  const parsed = parseInsightsArgs(args);
@@ -4084,6 +4096,8 @@ async function main() {
4084
4096
  return runAuth(argv.slice(1));
4085
4097
  if (argv[0] === 'sessions' || argv[0] === 'session')
4086
4098
  return runSessions(argv.slice(1));
4099
+ if (argv[0] === 'usage')
4100
+ return runUsage(argv.slice(1));
4087
4101
  if (argv[0] === 'insights')
4088
4102
  return runInsights(argv.slice(1));
4089
4103
  if (argv[0] === 'memory' && ['log', 'stats', undefined].includes(argv[1]))
@@ -0,0 +1,73 @@
1
+ import { readFile, stat, writeFile } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+ import { BRAND } from './brand.js';
4
+ import { scaffoldProjectWorkspace } from './project-scaffold.js';
5
+ async function exists(path) {
6
+ try {
7
+ await stat(path);
8
+ return true;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ /** Wire a freshly scaffolded vault to the current repo: Projects/<slug>/ + SANOOK.md memory stub. */
15
+ export async function linkBrainToProject(options) {
16
+ const cwd = options.cwd ?? process.cwd();
17
+ const brainPath = options.brainPath;
18
+ const title = options.title?.trim() || basename(cwd) || 'Project';
19
+ const today = options.today ?? new Date().toISOString().slice(0, 10);
20
+ const warnings = [];
21
+ const scaffold = await scaffoldProjectWorkspace({
22
+ brainPath,
23
+ title,
24
+ repoPath: cwd,
25
+ today,
26
+ });
27
+ if (!scaffold.ok && scaffold.skipped.length) {
28
+ warnings.push(...scaffold.warnings);
29
+ }
30
+ else if (scaffold.warnings.length) {
31
+ warnings.push(...scaffold.warnings);
32
+ }
33
+ const memoryFile = join(cwd, BRAND.memoryFileName);
34
+ let memoryCreated = false;
35
+ if (!(await exists(memoryFile))) {
36
+ const body = [
37
+ `# ${BRAND.productName} project memory`,
38
+ '',
39
+ `> Linked to second-brain vault: \`${brainPath}\``,
40
+ scaffold.ok || scaffold.slug ? `> Project workspace: \`Projects/${scaffold.slug}/\`` : '',
41
+ '',
42
+ '## Conventions',
43
+ '',
44
+ '- Decisions, gotchas, and preferences discovered in this repo belong here or in the vault.',
45
+ `- Session summaries are auto-written to \`Sessions/\` in the vault on exit (Ctrl+C / /quit).`,
46
+ '',
47
+ ]
48
+ .filter(Boolean)
49
+ .join('\n');
50
+ await writeFile(memoryFile, `${body}\n`, 'utf8');
51
+ memoryCreated = true;
52
+ }
53
+ else {
54
+ try {
55
+ const current = await readFile(memoryFile, 'utf8');
56
+ if (!current.includes(brainPath)) {
57
+ await writeFile(memoryFile, `${current.trimEnd()}\n\n<!-- ${BRAND.productName} -->\nsecond-brain: ${brainPath}\nproject: Projects/${scaffold.slug}/\n`, 'utf8');
58
+ }
59
+ }
60
+ catch {
61
+ warnings.push(`Could not update existing ${BRAND.memoryFileName}`);
62
+ }
63
+ }
64
+ return {
65
+ ok: scaffold.ok || scaffold.skipped.length > 0,
66
+ brainPath,
67
+ projectSlug: scaffold.slug,
68
+ projectRelDir: scaffold.relDir,
69
+ memoryFile,
70
+ memoryCreated,
71
+ warnings,
72
+ };
73
+ }
package/dist/brand.js CHANGED
@@ -24,6 +24,7 @@ export const BRAND_ENV = {
24
24
  disablePersistence: 'SANOOK_DISABLE_PERSISTENCE',
25
25
  disableUpdateCheck: 'SANOOK_DISABLE_UPDATE_CHECK',
26
26
  disableWorklog: 'SANOOK_DISABLE_WORKLOG',
27
+ disableUsageLedger: 'SANOOK_DISABLE_USAGE',
27
28
  trustProject: 'SANOOK_TRUST_PROJECT',
28
29
  };
29
30
  export function appHomePath(...parts) {
@@ -45,3 +46,6 @@ export function persistenceEnabled() {
45
46
  export function worklogEnabled() {
46
47
  return !envFlag(BRAND_ENV.disableWorklog);
47
48
  }
49
+ export function usageLedgerEnabled() {
50
+ return persistenceEnabled() && !envFlag(BRAND_ENV.disableUsageLedger);
51
+ }
package/dist/commands.js CHANGED
@@ -282,7 +282,10 @@ export function parseCommand(input, ctx) {
282
282
  return { handled: true, action: 'rewind' };
283
283
  case 'cost':
284
284
  case 'usage':
285
- return { handled: true, message: ctx.costSummary ?? '(ยังไม่มี usage รอบนี้)' };
285
+ return {
286
+ handled: true,
287
+ message: `${ctx.costSummary ?? '(ยังไม่มี usage รอบนี้)'}\n→ ${BRAND.cliName} usage daily`,
288
+ };
286
289
  case 'insights': {
287
290
  const parsed = parseInsightsArgs(args);
288
291
  if (parsed === null)
package/dist/config.js CHANGED
@@ -4,9 +4,12 @@ import { join } from 'node:path';
4
4
  import { appHomePath, appProjectPath, BRAND } from './brand.js';
5
5
  import { projectRoot, projectTrustStatus } from './trust.js';
6
6
  import { registerPricing } from './cost.js';
7
- export const CONFIG_DIR = appHomePath();
8
- const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
9
- const AUTH_PATH = join(CONFIG_DIR, 'auth.json'); // API keys (chmod 0600)
7
+ export function configHomeDir() {
8
+ return appHomePath();
9
+ }
10
+ function authPath() {
11
+ return join(configHomeDir(), 'auth.json');
12
+ }
10
13
  const AUTH_ENV_VAR_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
11
14
  const RESERVED_AUTH_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
12
15
  const PricingKeySchema = z.string().regex(/^[^:\s]+:\S+$/, 'key ต้องเป็น provider:model');
@@ -102,6 +105,9 @@ export async function agentTuning() {
102
105
  return { cacheTtl, thinkingBudget, compaction, contextCompression, summaryModel };
103
106
  }
104
107
  const warnedBadConfigKeys = new Set();
108
+ function globalConfigPath() {
109
+ return join(configHomeDir(), 'config.json');
110
+ }
105
111
  /**
106
112
  * Validate the merged config, but degrade gracefully: a malformed strict field (bad model/maxSteps/
107
113
  * permissionMode/budgetUsd/pricing in a hand-edited config.json) is dropped to its default with a
@@ -153,7 +159,7 @@ function sanitizeUntrustedProjectConfig(cfg) {
153
159
  * (config flat — shallow merge พอ; strip undefined ใน overrides กัน override ทับ default)
154
160
  */
155
161
  export async function loadConfig(overrides = {}, cwd = process.cwd()) {
156
- const global = await readJson(CONFIG_PATH);
162
+ const global = await readJson(globalConfigPath());
157
163
  const root = await projectRoot(cwd);
158
164
  const projectRaw = await readJson(appProjectPath(root, 'config.json'));
159
165
  const trust = await projectTrustStatus(root);
@@ -207,7 +213,7 @@ export function parsePricingOverride(raw) {
207
213
  /** ครั้งแรกที่รัน (ยังไม่มี global config) → ต้องทำ setup wizard */
208
214
  export async function isFirstRun() {
209
215
  try {
210
- await readFile(CONFIG_PATH, 'utf8');
216
+ await readFile(globalConfigPath(), 'utf8');
211
217
  return false;
212
218
  }
213
219
  catch {
@@ -216,32 +222,32 @@ export async function isFirstRun() {
216
222
  }
217
223
  /** บันทึก global config (model/provider/locale ที่เลือกตอน setup) */
218
224
  export async function saveGlobalConfig(cfg) {
219
- await mkdir(CONFIG_DIR, { recursive: true });
220
- const existing = await readJson(CONFIG_PATH);
221
- await writeFile(CONFIG_PATH, `${JSON.stringify({ ...existing, ...cfg }, null, 2)}\n`, { mode: 0o600 });
222
- await chmod(CONFIG_PATH, 0o600).catch(() => { });
225
+ await mkdir(configHomeDir(), { recursive: true });
226
+ const existing = await readJson(globalConfigPath());
227
+ await writeFile(globalConfigPath(), `${JSON.stringify({ ...existing, ...cfg }, null, 2)}\n`, { mode: 0o600 });
228
+ await chmod(globalConfigPath(), 0o600).catch(() => { });
223
229
  }
224
230
  /** บันทึก path ของ second-brain workspace ลง global config (merge — ไม่ทับ field อื่น) */
225
231
  export async function saveBrainPath(path) {
226
- await mkdir(CONFIG_DIR, { recursive: true });
227
- const existing = await readJson(CONFIG_PATH);
228
- await writeFile(CONFIG_PATH, `${JSON.stringify({ ...existing, brainPath: path }, null, 2)}\n`, { mode: 0o600 });
229
- await chmod(CONFIG_PATH, 0o600).catch(() => { });
232
+ await mkdir(configHomeDir(), { recursive: true });
233
+ const existing = await readJson(globalConfigPath());
234
+ await writeFile(globalConfigPath(), `${JSON.stringify({ ...existing, brainPath: path }, null, 2)}\n`, { mode: 0o600 });
235
+ await chmod(globalConfigPath(), 0o600).catch(() => { });
230
236
  }
231
237
  /** อ่าน config.json ดิบ (ไม่ apply default/schema) — สำหรับ `sanook config` */
232
238
  export async function readGlobalConfigRaw() {
233
- return readJson(CONFIG_PATH);
239
+ return readJson(globalConfigPath());
234
240
  }
235
241
  /** path ของ auth.json (ใช้โชว์ใน CLI เท่านั้น; ห้าม print raw secret) */
236
242
  export function authConfigPath() {
237
- return AUTH_PATH;
243
+ return authPath();
238
244
  }
239
245
  function isSafeAuthEnvVarName(name) {
240
246
  return AUTH_ENV_VAR_RE.test(name) && !RESERVED_AUTH_KEYS.has(name);
241
247
  }
242
248
  /** อ่าน auth.json ดิบแบบกรองเฉพาะ string values — caller ต้อง redact ก่อนโชว์ */
243
249
  export async function readStoredAuthRaw() {
244
- const raw = await readJson(AUTH_PATH);
250
+ const raw = await readJson(authPath());
245
251
  const auth = {};
246
252
  for (const [k, v] of Object.entries(raw)) {
247
253
  if (isSafeAuthEnvVarName(k) && typeof v === 'string')
@@ -251,44 +257,44 @@ export async function readStoredAuthRaw() {
251
257
  }
252
258
  /** merge patch ลง config.json (สำหรับ `sanook config set`) */
253
259
  export async function patchGlobalConfig(patch) {
254
- await mkdir(CONFIG_DIR, { recursive: true });
255
- const existing = await readJson(CONFIG_PATH);
256
- await writeFile(CONFIG_PATH, `${JSON.stringify({ ...existing, ...patch }, null, 2)}\n`, { mode: 0o600 });
257
- await chmod(CONFIG_PATH, 0o600).catch(() => { });
260
+ await mkdir(configHomeDir(), { recursive: true });
261
+ const existing = await readJson(globalConfigPath());
262
+ await writeFile(globalConfigPath(), `${JSON.stringify({ ...existing, ...patch }, null, 2)}\n`, { mode: 0o600 });
263
+ await chmod(globalConfigPath(), 0o600).catch(() => { });
258
264
  }
259
265
  /** บันทึก API key ลง ~/.sanook/auth.json (chmod 0600) + set env ทันทีสำหรับ session นี้ */
260
266
  export async function saveKey(envVar, key) {
261
267
  if (!isSafeAuthEnvVarName(envVar))
262
268
  throw new Error(`env var ไม่ถูกต้อง: ${envVar}`);
263
- await mkdir(CONFIG_DIR, { recursive: true });
269
+ await mkdir(configHomeDir(), { recursive: true });
264
270
  const auth = await readStoredAuthRaw();
265
271
  auth[envVar] = key;
266
- await writeFile(AUTH_PATH, `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
267
- await chmod(AUTH_PATH, 0o600); // เจ้าของอ่าน/เขียนเท่านั้น
272
+ await writeFile(authPath(), `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
273
+ await chmod(authPath(), 0o600); // เจ้าของอ่าน/เขียนเท่านั้น
268
274
  process.env[envVar] = key;
269
275
  }
270
276
  /** ลบ key ที่ Sanook เก็บไว้ใน auth.json (ไม่แตะ env จริงของ shell ภายนอก) */
271
277
  export async function removeStoredKey(envVar) {
272
278
  if (!isSafeAuthEnvVarName(envVar))
273
279
  return false;
274
- await mkdir(CONFIG_DIR, { recursive: true });
280
+ await mkdir(configHomeDir(), { recursive: true });
275
281
  const auth = await readStoredAuthRaw();
276
282
  if (!Object.prototype.hasOwnProperty.call(auth, envVar))
277
283
  return false;
278
284
  delete auth[envVar];
279
- await writeFile(AUTH_PATH, `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
280
- await chmod(AUTH_PATH, 0o600).catch(() => { });
285
+ await writeFile(authPath(), `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
286
+ await chmod(authPath(), 0o600).catch(() => { });
281
287
  delete process.env[envVar];
282
288
  return true;
283
289
  }
284
290
  /** ล้าง auth.json ที่ Sanook เก็บไว้ทั้งหมด */
285
291
  export async function clearStoredAuth() {
286
- await mkdir(CONFIG_DIR, { recursive: true });
292
+ await mkdir(configHomeDir(), { recursive: true });
287
293
  const auth = await readStoredAuthRaw();
288
294
  for (const envVar of Object.keys(auth))
289
295
  delete process.env[envVar];
290
- await writeFile(AUTH_PATH, '{}\n', { mode: 0o600 });
291
- await chmod(AUTH_PATH, 0o600).catch(() => { });
296
+ await writeFile(authPath(), '{}\n', { mode: 0o600 });
297
+ await chmod(authPath(), 0o600).catch(() => { });
292
298
  }
293
299
  /** โหลด key จาก auth.json เข้า env ตอน boot (ไม่ override env ที่ตั้งไว้แล้ว) */
294
300
  export async function loadKeysIntoEnv() {
package/dist/cost.js CHANGED
@@ -12,6 +12,14 @@ export const PRICING = {
12
12
  'openai:gpt-5.5': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 },
13
13
  'openai:gpt-5.4-mini': { input: 0.25, output: 2, cacheWrite: 0.25, cacheRead: 0.025 },
14
14
  'openai:gpt-5.3-codex': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 },
15
+ // OpenAI Codex delegate (ChatGPT plan — token counts are real; cost is estimated from API list price)
16
+ 'codex:gpt-5.5': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 },
17
+ 'codex:gpt-5.4': { input: 2.5, output: 15, cacheWrite: 2.5, cacheRead: 0.25 },
18
+ 'codex:gpt-5.4-mini': { input: 0.25, output: 2, cacheWrite: 0.25, cacheRead: 0.025 },
19
+ 'codex:gpt-5.3-codex': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 },
20
+ 'codex:gpt-5.2-codex': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 },
21
+ 'codex:gpt-5-codex': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 },
22
+ 'codex:gpt-5.3-codex-spark': { input: 0.25, output: 2, cacheWrite: 0.25, cacheRead: 0.025 },
15
23
  // Google Gemini (≤200k context tier)
16
24
  'google:gemini-2.5-pro': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.31 },
17
25
  'google:gemini-2.5-flash': { input: 0.3, output: 2.5, cacheWrite: 0.3, cacheRead: 0.075 },
@@ -147,4 +155,16 @@ export class CostMeter {
147
155
  const budget = this.budgetUsd != null ? ` / budget $${this.budgetUsd}` : '';
148
156
  return `tokens: ${total} (in ${this.inTok} · out ${this.outTok} · cache-read ${this.cacheReadTok} · cache-write ${this.cacheWriteTok}) · cost ${cost}${budget}`;
149
157
  }
158
+ snapshot() {
159
+ return {
160
+ specKey: this.specKey,
161
+ inputTokens: this.inTok,
162
+ outputTokens: this.outTok,
163
+ cacheReadTokens: this.cacheReadTok,
164
+ cacheWriteTokens: this.cacheWriteTok,
165
+ totalTokens: this.inTok + this.outTok + this.cacheReadTok + this.cacheWriteTok,
166
+ costUsd: this.spent,
167
+ hasPricing: this.hasPricing,
168
+ };
169
+ }
150
170
  }
@@ -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) };
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
  }
@@ -134,7 +134,17 @@ export const PROVIDERS = {
134
134
  requiresKey: false,
135
135
  localPlaceholderKey: 'codex',
136
136
  keyFormat: null,
137
- models: { default: 'gpt-5-codex', codex: 'gpt-5-codex' },
137
+ models: {
138
+ default: 'gpt-5.5',
139
+ codex: 'gpt-5.5',
140
+ '5.5': 'gpt-5.5',
141
+ '5.4': 'gpt-5.4',
142
+ '5.4-mini': 'gpt-5.4-mini',
143
+ '5.3-codex': 'gpt-5.3-codex',
144
+ '5.2-codex': 'gpt-5.2-codex',
145
+ '5-codex': 'gpt-5-codex',
146
+ spark: 'gpt-5.3-codex-spark',
147
+ },
138
148
  create: () => {
139
149
  throw new Error('codex เป็น delegate provider — ใช้ผ่าน codex subprocess ไม่ใช่ Vercel AI SDK');
140
150
  },
@@ -0,0 +1,103 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { BRAND, persistenceEnabled } from './brand.js';
3
+ import { createBrainNote } from './brain-new.js';
4
+ import { getBrainPath } from './memory.js';
5
+ import { PROVIDERS, parseSpec } from './providers/registry.js';
6
+ import { makeSummarizer } from './summarize.js';
7
+ import { distilledFactsFromMessages } from './session-distill.js';
8
+ import { saveSession } from './session.js';
9
+ function transcriptFromTurns(turns) {
10
+ return turns
11
+ .filter((t) => t.role === 'user' || t.role === 'assistant')
12
+ .map((t) => `${t.role === 'user' ? 'User' : 'Assistant'}: ${t.text.trim()}`)
13
+ .filter((line) => line.length > 8)
14
+ .join('\n\n');
15
+ }
16
+ function sessionTitleFromHistory(history) {
17
+ const firstUser = history.find((t) => t.role === 'user')?.text.trim();
18
+ if (!firstUser)
19
+ return 'repl session';
20
+ const cleaned = firstUser.replace(/^\/\w+\s*/, '').trim();
21
+ return cleaned.split(/\s+/).slice(0, 8).join(' ').slice(0, 72) || 'repl session';
22
+ }
23
+ function injectSessionSummary(template, summary, facts) {
24
+ const summaryBlock = [summary.trim(), facts.length ? `\n### Key facts\n${facts.map((f) => `- ${f}`).join('\n')}` : '']
25
+ .filter(Boolean)
26
+ .join('\n');
27
+ if (/^## Summary\s*$/m.test(template)) {
28
+ return template.replace(/^## Summary\s*$/m, `## Summary\n\n${summaryBlock}`);
29
+ }
30
+ return `${template.trimEnd()}\n\n## Summary\n\n${summaryBlock}\n`;
31
+ }
32
+ async function summarizeSession(model, transcript, messages) {
33
+ const provider = parseSpec(model).provider;
34
+ if (PROVIDERS[provider]?.kind !== 'delegate' && transcript.trim().length > 40) {
35
+ try {
36
+ const text = await makeSummarizer(model)(transcript);
37
+ if (text.trim())
38
+ return text.trim();
39
+ }
40
+ catch {
41
+ // fall through to heuristic distill
42
+ }
43
+ }
44
+ const facts = distilledFactsFromMessages(messages);
45
+ if (facts.length)
46
+ return facts.map((f) => `- ${f}`).join('\n');
47
+ const lines = transcript.split('\n\n').slice(-6);
48
+ return lines.length ? lines.join('\n\n') : 'Session ended with no durable transcript.';
49
+ }
50
+ /** Persist REPL session + write a Sessions/ note in the configured second-brain vault. */
51
+ export async function finalizeReplSession(options) {
52
+ const hasConversation = options.messages.length > 0 || options.history.some((t) => t.role === 'user' || t.role === 'assistant');
53
+ if (!hasConversation || !persistenceEnabled()) {
54
+ return { sessionSaved: false };
55
+ }
56
+ const now = new Date().toISOString();
57
+ const session = {
58
+ id: options.sessionId,
59
+ title: sessionTitleFromHistory(options.history),
60
+ created: options.sessionCreated,
61
+ updated: now,
62
+ model: options.model,
63
+ cwd: options.cwd,
64
+ messages: options.messages,
65
+ };
66
+ await saveSession(session);
67
+ const brainPath = await getBrainPath();
68
+ if (!brainPath)
69
+ return { sessionSaved: true };
70
+ const transcript = transcriptFromTurns(options.history);
71
+ const summary = await summarizeSession(options.model, transcript, options.messages);
72
+ const title = sessionTitleFromHistory(options.history);
73
+ const slugSuffix = options.sessionId.slice(-6);
74
+ const today = now.slice(0, 10);
75
+ const output = `Sessions/${today}-${slugSuffix}-session.md`;
76
+ const report = await createBrainNote({
77
+ brainPath,
78
+ type: 'session',
79
+ title,
80
+ output,
81
+ force: true,
82
+ today,
83
+ });
84
+ if (!report.ok || !report.path)
85
+ return { sessionSaved: true };
86
+ const facts = distilledFactsFromMessages(options.messages);
87
+ const raw = await readFile(report.path, 'utf8');
88
+ const next = injectSessionSummary(raw, summary, facts.slice(0, 8));
89
+ await writeFile(report.path, next, 'utf8');
90
+ return {
91
+ sessionSaved: true,
92
+ brainNoteRel: report.relPath,
93
+ brainNotePath: report.path,
94
+ };
95
+ }
96
+ export function formatFinalizeMessage(result) {
97
+ if (!result.sessionSaved)
98
+ return undefined;
99
+ if (result.brainNoteRel) {
100
+ return `${BRAND.cliName}: session saved · second-brain → [[${result.brainNoteRel.replace(/\.md$/i, '')}]]`;
101
+ }
102
+ return `${BRAND.cliName}: session saved`;
103
+ }
package/dist/ui/app.js CHANGED
@@ -6,6 +6,7 @@ import { Box, Text, useApp, useInput, useStdout } from 'ink';
6
6
  import { homedir } from 'node:os';
7
7
  import { BUILTIN_COMMANDS, HELP_TEXT, expandCustomCommand, loadCustomCommands, parseCommand, parseSlashInvocation, } from '../commands.js';
8
8
  import { runAgent } from '../loop.js';
9
+ import { finalizeReplSession, formatFinalizeMessage } from '../session-brain.js';
9
10
  import { saveSession, newSessionId, listSessions, removeSession, renameSession } from '../session.js';
10
11
  import { TOOL_CATALOG } from '../tool-catalog.js';
11
12
  import { getBrainPath, appendBrainWorklog } from '../memory.js';
@@ -53,6 +54,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
53
54
  });
54
55
  const [streaming, setStreaming] = useState('');
55
56
  const [thinking, setThinking] = useState('');
57
+ const [agentStatus, setAgentStatus] = useState('');
56
58
  const [toolTrail, setToolTrail] = useState([]);
57
59
  const [busy, setBusy] = useState(false);
58
60
  const [model, setModel] = useState(initialModel);
@@ -74,6 +76,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
74
76
  const msgsRef = useRef(initialHistory ?? []); // conversation จริงสำหรับ LLM (สะสมข้ามรอบ)
75
77
  const sessionId = useRef(newSessionId());
76
78
  const sessionCreated = useRef(new Date().toISOString());
79
+ const exitingRef = useRef(false);
77
80
  const approvalResolve = useRef(null);
78
81
  const replHistory = useRef(loadHistory()); // prompt เก่า (persist) สำหรับ ↑/↓
79
82
  const checkpoints = useRef([]);
@@ -259,6 +262,26 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
259
262
  setCompletionIndex(0);
260
263
  return true;
261
264
  };
265
+ const requestExit = () => {
266
+ if (exitingRef.current)
267
+ return;
268
+ exitingRef.current = true;
269
+ void finalizeReplSession({
270
+ sessionId: sessionId.current,
271
+ sessionCreated: sessionCreated.current,
272
+ model,
273
+ cwd,
274
+ messages: msgsRef.current,
275
+ history: history.map((turn) => ({ role: turn.role, text: turn.text })),
276
+ })
277
+ .then((result) => {
278
+ const note = formatFinalizeMessage(result);
279
+ if (note)
280
+ process.stderr.write(`\n${note}\n`);
281
+ exit();
282
+ })
283
+ .catch(() => exit());
284
+ };
262
285
  // /diff /undo — git-backed (execFile ไม่ผ่าน shell)
263
286
  async function runGit(args, label) {
264
287
  try {
@@ -817,7 +840,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
817
840
  if (editor.value)
818
841
  editor.reset(); // Ctrl+C ครั้งแรก = ล้างบรรทัด, ว่างแล้ว = ออก
819
842
  else
820
- exit();
843
+ requestExit();
821
844
  }
822
845
  });
823
846
  /** ย้อน 1 turn — คืนไฟล์ (git, recoverable) + ตัดบทสนทนากลับ */
@@ -910,7 +933,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
910
933
  if (cmd.handled) {
911
934
  addTurn('user', displayText);
912
935
  if (cmd.action === 'quit')
913
- return exit();
936
+ return requestExit();
914
937
  if (cmd.action === 'clear') {
915
938
  msgsRef.current = [];
916
939
  checkpoints.current = [];
@@ -1023,6 +1046,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1023
1046
  resetLiveToolTrail();
1024
1047
  resetLiveThinking();
1025
1048
  setStreaming('');
1049
+ setAgentStatus('Starting…');
1026
1050
  setBusy(true);
1027
1051
  let buf = '';
1028
1052
  let reasoningBuf = '';
@@ -1039,8 +1063,13 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1039
1063
  permissionMode,
1040
1064
  approve: requestApproval,
1041
1065
  signal: ac.signal,
1066
+ usageMeta: { sessionId: sessionId.current, source: 'repl' },
1042
1067
  onEvent: (e) => {
1043
- if (e.type === 'text') {
1068
+ if (e.type === 'status' && typeof e.detail === 'string') {
1069
+ setAgentStatus(e.detail);
1070
+ }
1071
+ else if (e.type === 'text') {
1072
+ setAgentStatus((prev) => (prev.startsWith('Codex') || prev.startsWith('Agent') ? 'Writing…' : prev));
1044
1073
  buf += e.text ?? '';
1045
1074
  const now = Date.now();
1046
1075
  if (now - lastFlush > 80) {
@@ -1103,6 +1132,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1103
1132
  }
1104
1133
  finally {
1105
1134
  setStreaming('');
1135
+ setAgentStatus('');
1106
1136
  resetLiveThinking();
1107
1137
  resetLiveToolTrail();
1108
1138
  setBusy(false);
@@ -1122,7 +1152,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1122
1152
  const transcriptLimit = transcriptWindowSize(stdout?.rows);
1123
1153
  const transcriptView = getTranscriptWindow(history.length, transcriptLimit, transcriptScroll);
1124
1154
  const visibleHistory = history.slice(transcriptView.start, transcriptView.end);
1125
- return (_jsxs(Box, { flexDirection: "column", children: [history.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Banner, { columns: columns, model: model, mode: permissionMode === 'ask' ? 'ask' : 'auto', signals: bannerSignals }), _jsx(SessionPanel, { columns: columns, cwd: cwd, mcp: startupReadiness.mcp, model: model, mode: permissionMode === 'ask' ? 'ask' : 'auto', skills: startupReadiness.skills })] })) : null, transcriptView.showOlder ? (_jsxs(Text, { dimColor: true, children: ["\u2026 ", transcriptView.start, " older turns \u00B7 PgUp/Ctrl+U scroll \u00B7 PgDn/Ctrl+D newer"] })) : null, visibleHistory.map((turn) => (_jsx(TurnView, { columns: columns, thinkingMode: thinkingMode, toolTrailMode: toolTrailMode, turn: turn }, `${historyResetKey}-${turn.id}`))), transcriptView.showNewer ? (_jsxs(Text, { dimColor: true, children: ["\u2026 ", transcriptView.scrollFromBottom, " newer turns hidden \u00B7 PgDn/Ctrl+D to catch up"] })) : null, thinkingView.length ? _jsx(ThinkingView, { columns: columns, mode: thinkingMode, text: thinking }) : null, streaming ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(StreamingMarkdownText, { columns: columns, text: streaming }) })) : null, toolTrailView.length ? (_jsx(ToolTrailView, { columns: columns, items: toolTrail, mode: toolTrailMode })) : null, _jsx(FloatingOverlay, { columns: columns, overlay: overlay, pageSize: pagerPageSize }), _jsx(CompletionOverlay, { columns: columns, items: completions, selected: selectedCompletion }), queued.length ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["queued (", queued.length, ") \u00B7 \u2191\u2193 select \u00B7 Ctrl+X delete \u00B7 Esc clears"] }), queueWindow.showLead ? _jsx(Text, { dimColor: true, children: " \u2026" }) : null, queued.slice(queueWindow.start, queueWindow.end).map((q, i) => (_jsxs(Text, { color: queueWindow.start + i === activeQueueIndex ? 'yellow' : undefined, dimColor: queueWindow.start + i !== activeQueueIndex, children: [queueWindow.start + i === activeQueueIndex ? '›' : ' ', " ", queueWindow.start + i + 1, ".", ' ', compactPreview(q, Math.max(16, columns - 10))] }, `${queueWindow.start + i}-${q.slice(0, 16)}`))), queueWindow.showTail ? _jsxs(Text, { dimColor: true, children: [" \u2026and ", queued.length - queueWindow.end, " more"] }) : null] })) : null, approvalReq ? (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34\u0E23\u0E31\u0E19 ", approvalReq.tool, "?"] }), _jsx(Text, { dimColor: true, children: approvalReq.summary }), _jsx(Text, { dimColor: true, children: "y = \u0E23\u0E31\u0E19 \u00B7 n = \u0E1B\u0E0F\u0E34\u0E40\u0E2A\u0E18" })] })) : (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: busy ? 'gray' : 'blue', paddingX: 1, children: [_jsx(Text, { color: busy ? 'gray' : 'cyan', children: busy ? '· ' : '› ' }), _jsx(InputView, { value: editor.value, cursor: editor.cursor, busy: busy })] })), _jsx(Text, { dimColor: true, children: footerStatus({
1155
+ return (_jsxs(Box, { flexDirection: "column", children: [history.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Banner, { columns: columns, model: model, mode: permissionMode === 'ask' ? 'ask' : 'auto', signals: bannerSignals }), _jsx(SessionPanel, { columns: columns, cwd: cwd, mcp: startupReadiness.mcp, model: model, mode: permissionMode === 'ask' ? 'ask' : 'auto', skills: startupReadiness.skills })] })) : null, transcriptView.showOlder ? (_jsxs(Text, { dimColor: true, children: ["\u2026 ", transcriptView.start, " older turns \u00B7 PgUp/Ctrl+U scroll \u00B7 PgDn/Ctrl+D newer"] })) : null, visibleHistory.map((turn) => (_jsx(TurnView, { columns: columns, thinkingMode: thinkingMode, toolTrailMode: toolTrailMode, turn: turn }, `${historyResetKey}-${turn.id}`))), transcriptView.showNewer ? (_jsxs(Text, { dimColor: true, children: ["\u2026 ", transcriptView.scrollFromBottom, " newer turns hidden \u00B7 PgDn/Ctrl+D to catch up"] })) : null, thinkingView.length ? _jsx(ThinkingView, { columns: columns, mode: thinkingMode, text: thinking }) : null, streaming ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(StreamingMarkdownText, { columns: columns, text: streaming }) })) : null, toolTrailView.length ? (_jsx(ToolTrailView, { columns: columns, items: toolTrail, mode: toolTrailMode })) : null, _jsx(FloatingOverlay, { columns: columns, overlay: overlay, pageSize: pagerPageSize }), _jsx(CompletionOverlay, { columns: columns, items: completions, selected: selectedCompletion }), queued.length ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["queued (", queued.length, ") \u00B7 \u2191\u2193 select \u00B7 Ctrl+X delete \u00B7 Esc clears"] }), queueWindow.showLead ? _jsx(Text, { dimColor: true, children: " \u2026" }) : null, queued.slice(queueWindow.start, queueWindow.end).map((q, i) => (_jsxs(Text, { color: queueWindow.start + i === activeQueueIndex ? 'yellow' : undefined, dimColor: queueWindow.start + i !== activeQueueIndex, children: [queueWindow.start + i === activeQueueIndex ? '›' : ' ', " ", queueWindow.start + i + 1, ".", ' ', compactPreview(q, Math.max(16, columns - 10))] }, `${queueWindow.start + i}-${q.slice(0, 16)}`))), queueWindow.showTail ? _jsxs(Text, { dimColor: true, children: [" \u2026and ", queued.length - queueWindow.end, " more"] }) : null] })) : null, approvalReq ? (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34\u0E23\u0E31\u0E19 ", approvalReq.tool, "?"] }), _jsx(Text, { dimColor: true, children: approvalReq.summary }), _jsx(Text, { dimColor: true, children: "y = \u0E23\u0E31\u0E19 \u00B7 n = \u0E1B\u0E0F\u0E34\u0E40\u0E2A\u0E18" })] })) : (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: busy ? 'gray' : 'blue', paddingX: 1, children: [_jsx(Text, { color: busy ? 'gray' : 'cyan', children: busy ? '· ' : '› ' }), _jsx(InputView, { value: editor.value, cursor: editor.cursor, busy: busy, agentStatus: agentStatus, toolTrail: toolTrail })] })), _jsx(Text, { dimColor: true, children: footerStatus({
1126
1156
  branch: gitBranch,
1127
1157
  backgroundTaskCount: bgTaskCount,
1128
1158
  busy,
@@ -1138,9 +1168,12 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1138
1168
  }) })] }));
1139
1169
  }
1140
1170
  /** input ที่มี cursor (inverse) + placeholder — minimal; รับ input ได้แม้ busy (ต่อคิว) */
1141
- function InputView({ value, cursor, busy }) {
1142
- if (busy && !value)
1143
- return _jsx(Text, { dimColor: true, children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E17\u0E33\u0E07\u0E32\u0E19\u2026 Esc/Ctrl+C \u0E2B\u0E22\u0E38\u0E14 \u00B7 \u0E1E\u0E34\u0E21\u0E1E\u0E4C\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E15\u0E48\u0E2D\u0E04\u0E34\u0E27 (\u23CE)" });
1171
+ function InputView({ value, cursor, busy, agentStatus, toolTrail, }) {
1172
+ if (busy && !value) {
1173
+ const runningTool = toolTrail?.find((item) => item.status === 'running');
1174
+ const detail = agentStatus || (runningTool ? `Tool · ${runningTool.name}` : 'Working…');
1175
+ return (_jsxs(Text, { dimColor: true, children: [detail, " \u00B7 Esc/Ctrl+C \u0E2B\u0E22\u0E38\u0E14 \u00B7 \u0E1E\u0E34\u0E21\u0E1E\u0E4C\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E15\u0E48\u0E2D\u0E04\u0E34\u0E27 (\u23CE)"] }));
1176
+ }
1144
1177
  if (!busy && !value)
1145
1178
  return _jsx(Text, { dimColor: true, children: "\u0E16\u0E32\u0E21\u0E2D\u0E30\u0E44\u0E23\u0E01\u0E47\u0E44\u0E14\u0E49 \u2014 /help \u0E14\u0E39\u0E04\u0E33\u0E2A\u0E31\u0E48\u0E07 \u00B7 /tools \u0E14\u0E39 tools \u00B7 @\u0E44\u0E1F\u0E25\u0E4C \u0E41\u0E19\u0E1A context/\u0E23\u0E39\u0E1B" });
1146
1179
  const before = value.slice(0, cursor);
package/dist/ui/render.js CHANGED
@@ -48,6 +48,7 @@ export function Root({ needsSetup, appProps }) {
48
48
  const onComplete = (a) => {
49
49
  void (async () => {
50
50
  const { scaffoldBrain, BRAIN_DEFAULTS, expandHome, wireBrainMcp } = await import('../brain.js');
51
+ const { linkBrainToProject } = await import('../brain-link.js');
51
52
  const today = new Date().toISOString().slice(0, 10);
52
53
  const target = expandHome(a.path);
53
54
  try {
@@ -60,8 +61,12 @@ export function Root({ needsSetup, appProps }) {
60
61
  });
61
62
  await saveBrainPath(target);
62
63
  const wired = await wireBrainMcp(target).catch(() => 'skip');
64
+ const linked = await linkBrainToProject({ brainPath: target, cwd: process.cwd(), today }).catch(() => null);
65
+ const linkNote = linked?.projectRelDir
66
+ ? ` · project ${linked.projectRelDir} · ${linked.memoryCreated ? 'created' : 'linked'} ${BRAND.memoryFileName}`
67
+ : '';
63
68
  setBrainNote(`✅ second-brain — ${target} · สร้าง ${res.created.length} ไฟล์ · ` +
64
- `${wired === 'added' ? 'wire filesystem MCP เข้า vault แล้ว' : 'MCP เดิมอยู่แล้ว (ไม่ทับ)'} · เปิดใน Obsidian: Open folder as vault`);
69
+ `${wired === 'added' ? 'wire filesystem MCP เข้า vault แล้ว' : 'MCP เดิมอยู่แล้ว (ไม่ทับ)'}${linkNote} · เปิดใน Obsidian: Open folder as vault`);
65
70
  }
66
71
  catch (e) {
67
72
  setBrainNote(`⚠ สร้าง second-brain ไม่สำเร็จ: ${e.message} — ลองใหม่ด้วย ${'`'}sanook brain init${'`'}`);
@@ -92,6 +97,7 @@ export function startBrainSetup() {
92
97
  const onComplete = (a) => {
93
98
  void (async () => {
94
99
  const { scaffoldBrain, BRAIN_DEFAULTS, expandHome, wireBrainMcp } = await import('../brain.js');
100
+ const { linkBrainToProject } = await import('../brain-link.js');
95
101
  const today = new Date().toISOString().slice(0, 10);
96
102
  const target = expandHome(a.path);
97
103
  const res = await scaffoldBrain(target, {
@@ -103,9 +109,12 @@ export function startBrainSetup() {
103
109
  });
104
110
  await saveBrainPath(target);
105
111
  const wired = await wireBrainMcp(target).catch(() => 'skip');
112
+ const linked = await linkBrainToProject({ brainPath: target, cwd: process.cwd(), today }).catch(() => null);
106
113
  unmount();
114
+ const linkLine = linked?.projectRelDir ? `\n linked repo → ${linked.projectRelDir} · ${BRAND.memoryFileName} in cwd` : '';
107
115
  process.stdout.write(`\n✅ second-brain — ${target}\n สร้าง ${res.created.length} · ข้าม ${res.skipped.length} (มีอยู่แล้ว ไม่ทับ)` +
108
116
  `\n ${wired === 'added' ? 'wire filesystem MCP เข้า vault แล้ว (agent อ่าน/เขียนได้)' : 'MCP: มี server เดิมอยู่แล้ว (ไม่ทับ)'}` +
117
+ `${linkLine}` +
109
118
  `\n เปิดใน Obsidian: Open folder as vault\n`);
110
119
  resolve();
111
120
  })();
@@ -0,0 +1,160 @@
1
+ import { BRAND } from './brand.js';
2
+ import { takeValue } from './cli-option-values.js';
3
+ import { aggregateUsageEvents, loadUsageEvents, usageEventsPath } from './usage-ledger.js';
4
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
5
+ function shiftDays(days) {
6
+ const d = new Date();
7
+ d.setDate(d.getDate() - days);
8
+ return d.toISOString().slice(0, 10);
9
+ }
10
+ export function parseUsageArgs(args) {
11
+ if (args.includes('-h') || args.includes('--help'))
12
+ return null;
13
+ let mode = 'daily';
14
+ let since;
15
+ let until;
16
+ let days = 30;
17
+ let json = false;
18
+ let noColor = false;
19
+ const positional = [];
20
+ for (let i = 0; i < args.length; i++) {
21
+ const arg = args[i];
22
+ if (arg === '--json')
23
+ json = true;
24
+ else if (arg === '--no-color')
25
+ noColor = true;
26
+ else if (arg === '--since') {
27
+ const picked = takeValue(args, i);
28
+ if (!picked.value || !DATE_RE.test(picked.value))
29
+ return null;
30
+ since = picked.value;
31
+ i = picked.nextIndex;
32
+ }
33
+ else if (arg.startsWith('--since=')) {
34
+ since = arg.slice('--since='.length);
35
+ if (!DATE_RE.test(since))
36
+ return null;
37
+ }
38
+ else if (arg === '--until') {
39
+ const picked = takeValue(args, i);
40
+ if (!picked.value || !DATE_RE.test(picked.value))
41
+ return null;
42
+ until = picked.value;
43
+ i = picked.nextIndex;
44
+ }
45
+ else if (arg.startsWith('--until=')) {
46
+ until = arg.slice('--until='.length);
47
+ if (!DATE_RE.test(until))
48
+ return null;
49
+ }
50
+ else if (arg === '--days') {
51
+ const picked = takeValue(args, i);
52
+ const n = Number(picked.value);
53
+ if (!Number.isInteger(n) || n <= 0)
54
+ return null;
55
+ days = n;
56
+ i = picked.nextIndex;
57
+ }
58
+ else if (arg.startsWith('--days=')) {
59
+ const n = Number(arg.slice('--days='.length));
60
+ if (!Number.isInteger(n) || n <= 0)
61
+ return null;
62
+ days = n;
63
+ }
64
+ else if (!arg.startsWith('-'))
65
+ positional.push(arg);
66
+ else
67
+ return null;
68
+ }
69
+ if (positional[0]) {
70
+ if (!['daily', 'weekly', 'monthly', 'session'].includes(positional[0]))
71
+ return null;
72
+ mode = positional[0];
73
+ }
74
+ if (!since)
75
+ since = shiftDays(days - 1);
76
+ if (!until)
77
+ until = new Date().toISOString().slice(0, 10);
78
+ return { mode, since, until, days, json, noColor };
79
+ }
80
+ function fmt(n) {
81
+ return n.toLocaleString('en-US');
82
+ }
83
+ function fmtCost(n) {
84
+ return n > 0 ? `$${n.toFixed(2)}` : '$0.00';
85
+ }
86
+ function renderTable(title, rows, wide) {
87
+ if (!rows.length) {
88
+ return [
89
+ `╭${'─'.repeat(Math.max(42, title.length + 4))}╮`,
90
+ `│ ${title.padEnd(Math.max(40, title.length + 2))} │`,
91
+ `╰${'─'.repeat(Math.max(42, title.length + 4))}╯`,
92
+ '',
93
+ `(no usage recorded — run ${BRAND.cliName} and complete a turn first)`,
94
+ `ledger: ${usageEventsPath()}`,
95
+ ].join('\n');
96
+ }
97
+ const lines = [];
98
+ lines.push(`╭${'─'.repeat(title.length + 4)}╮`);
99
+ lines.push(`│ ${title} │`);
100
+ lines.push(`╰${'─'.repeat(title.length + 4)}╯`);
101
+ lines.push('');
102
+ if (wide) {
103
+ lines.push('┌────────────┬─────────┬──────────────────┬─────────┬─────────┬────────────┬────────────┐');
104
+ lines.push('│ Period │ Turns │ Models │ Input │ Output │ Cache R/W │ Cost (USD) │');
105
+ lines.push('├────────────┼─────────┼──────────────────┼─────────┼─────────┼────────────┼────────────┤');
106
+ for (const row of rows) {
107
+ const models = row.models.join(' ').slice(0, 16).padEnd(16);
108
+ const cache = `${fmt(row.cacheReadTokens)}/${fmt(row.cacheWriteTokens)}`.padStart(10);
109
+ lines.push(`│ ${row.label.padEnd(10)} │ ${String(row.turns).padStart(7)} │ ${models} │ ${fmt(row.inputTokens).padStart(7)} │ ${fmt(row.outputTokens).padStart(7)} │ ${cache} │ ${fmtCost(row.costUsd).padStart(10)} │`);
110
+ }
111
+ lines.push('└────────────┴─────────┴──────────────────┴─────────┴─────────┴────────────┴────────────┘');
112
+ }
113
+ else {
114
+ lines.push('┌────────────┬──────────────────┬─────────┬─────────┬────────────┐');
115
+ lines.push('│ Period │ Models │ Input │ Output │ Cost (USD) │');
116
+ lines.push('├────────────┼──────────────────┼─────────┼─────────┼────────────┤');
117
+ for (const row of rows) {
118
+ const models = row.models.join(' ').slice(0, 16).padEnd(16);
119
+ lines.push(`│ ${row.label.padEnd(10)} │ ${models} │ ${fmt(row.inputTokens).padStart(7)} │ ${fmt(row.outputTokens).padStart(7)} │ ${fmtCost(row.costUsd).padStart(10)} │`);
120
+ }
121
+ lines.push('└────────────┴──────────────────┴─────────┴─────────┴────────────┘');
122
+ }
123
+ const totalCost = rows.reduce((sum, row) => sum + row.costUsd, 0);
124
+ const totalTokens = rows.reduce((sum, row) => sum + row.totalTokens, 0);
125
+ lines.push('');
126
+ lines.push(`totals: ${fmt(totalTokens)} tokens · ${fmtCost(totalCost)} estimated · ledger: ${usageEventsPath()}`);
127
+ return lines.join('\n');
128
+ }
129
+ export async function renderUsageReport(options) {
130
+ const events = await loadUsageEvents({ since: options.since, until: options.until });
131
+ const rows = aggregateUsageEvents(events, options.mode);
132
+ if (options.json) {
133
+ return JSON.stringify({
134
+ agent: BRAND.cliName,
135
+ mode: options.mode,
136
+ since: options.since,
137
+ until: options.until,
138
+ events: events.length,
139
+ rows,
140
+ ledger: usageEventsPath(),
141
+ }, null, 2);
142
+ }
143
+ const title = options.mode === 'daily'
144
+ ? `${BRAND.productName} Usage Report — Daily`
145
+ : options.mode === 'weekly'
146
+ ? `${BRAND.productName} Usage Report — Weekly`
147
+ : options.mode === 'monthly'
148
+ ? `${BRAND.productName} Usage Report — Monthly`
149
+ : `${BRAND.productName} Usage Report — Sessions`;
150
+ const wide = (process.stdout.columns ?? 100) >= 100;
151
+ return renderTable(title, rows, wide);
152
+ }
153
+ export function usageHelpText() {
154
+ return [
155
+ `ใช้: ${BRAND.cliName} usage [daily|weekly|monthly|session] [--days N] [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--json]`,
156
+ '',
157
+ 'บันทึก token/cost ทุก agent turn ลง ~/.sanook/usage/events.jsonl (ccusage-style local ledger).',
158
+ 'ปิดได้ด้วย SANOOK_DISABLE_USAGE=1',
159
+ ].join('\n');
160
+ }
@@ -0,0 +1,169 @@
1
+ import { appendFile, mkdir, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { appHomePath, BRAND, usageLedgerEnabled } from './brand.js';
5
+ const USAGE_DIR_NAME = 'usage';
6
+ export function usageDirPath() {
7
+ return appHomePath(USAGE_DIR_NAME);
8
+ }
9
+ export function usageEventsPath() {
10
+ return join(usageDirPath(), 'events.jsonl');
11
+ }
12
+ function localDate(iso) {
13
+ const d = new Date(iso);
14
+ if (!Number.isFinite(d.getTime()))
15
+ return iso.slice(0, 10);
16
+ const y = d.getFullYear();
17
+ const m = String(d.getMonth() + 1).padStart(2, '0');
18
+ const day = String(d.getDate()).padStart(2, '0');
19
+ return `${y}-${m}-${day}`;
20
+ }
21
+ function num(value) {
22
+ const n = typeof value === 'number' ? value : Number(value);
23
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
24
+ }
25
+ /** Parse Codex JSONL turn.completed usage payloads into AI SDK Usage shape. */
26
+ export function usageFromCodexPayload(raw) {
27
+ if (!raw || typeof raw !== 'object')
28
+ return null;
29
+ const u = raw;
30
+ const input = num(u.input_tokens ?? u.inputTokens ?? u.prompt_tokens ?? u.promptTokens);
31
+ const output = num(u.output_tokens ?? u.outputTokens ?? u.completion_tokens ?? u.completionTokens);
32
+ const cacheRead = num(u.cache_read_input_tokens ?? u.cached_input_tokens ?? u.cacheReadInputTokens ?? u.cachedInputTokens);
33
+ if (!input && !output && !cacheRead)
34
+ return null;
35
+ return { inputTokens: input, outputTokens: output, cachedInputTokens: cacheRead };
36
+ }
37
+ export async function appendUsageEvent(event) {
38
+ if (!usageLedgerEnabled())
39
+ return;
40
+ await mkdir(usageDirPath(), { recursive: true });
41
+ await appendFile(usageEventsPath(), `${JSON.stringify(event)}\n`, { mode: 0o600 });
42
+ }
43
+ export function recordAgentUsage(options) {
44
+ if (!usageLedgerEnabled())
45
+ return;
46
+ const snap = options.cost.snapshot();
47
+ const ts = new Date().toISOString();
48
+ const event = {
49
+ id: randomUUID(),
50
+ ts,
51
+ date: localDate(ts),
52
+ sessionId: options.sessionId,
53
+ source: options.source ?? 'headless',
54
+ model: options.model,
55
+ cwd: options.cwd,
56
+ inputTokens: snap.inputTokens,
57
+ outputTokens: snap.outputTokens,
58
+ cacheReadTokens: snap.cacheReadTokens,
59
+ cacheWriteTokens: snap.cacheWriteTokens,
60
+ totalTokens: snap.totalTokens,
61
+ costUsd: snap.hasPricing ? snap.costUsd : null,
62
+ priced: snap.hasPricing,
63
+ };
64
+ void appendUsageEvent(event).catch(() => { });
65
+ }
66
+ export async function loadUsageEvents(options = {}) {
67
+ let raw = '';
68
+ try {
69
+ raw = await readFile(usageEventsPath(), 'utf8');
70
+ }
71
+ catch {
72
+ return [];
73
+ }
74
+ const since = options.since;
75
+ const until = options.until;
76
+ const out = [];
77
+ for (const line of raw.split('\n')) {
78
+ const t = line.trim();
79
+ if (!t)
80
+ continue;
81
+ try {
82
+ const parsed = JSON.parse(t);
83
+ if (!parsed?.ts || typeof parsed.model !== 'string')
84
+ continue;
85
+ const date = parsed.date || localDate(parsed.ts);
86
+ if (since && date < since)
87
+ continue;
88
+ if (until && date > until)
89
+ continue;
90
+ out.push({ ...parsed, date });
91
+ }
92
+ catch {
93
+ // skip malformed line
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+ function mergeModels(map, model) {
99
+ const label = model.includes(':') ? model.split(':').slice(1).join(':') : model;
100
+ map.set(label, (map.get(label) ?? 0) + 1);
101
+ }
102
+ function topModels(map) {
103
+ return [...map.entries()]
104
+ .sort((a, b) => b[1] - a[1])
105
+ .slice(0, 3)
106
+ .map(([model]) => `• ${model}`);
107
+ }
108
+ function weekKey(date) {
109
+ const d = new Date(`${date}T12:00:00`);
110
+ const day = d.getDay();
111
+ const diff = day === 0 ? -6 : 1 - day;
112
+ d.setDate(d.getDate() + diff);
113
+ return localDate(d.toISOString());
114
+ }
115
+ function monthKey(date) {
116
+ return date.slice(0, 7);
117
+ }
118
+ export function aggregateUsageEvents(events, mode) {
119
+ const groups = new Map();
120
+ for (const event of events) {
121
+ const key = mode === 'daily'
122
+ ? event.date
123
+ : mode === 'weekly'
124
+ ? weekKey(event.date)
125
+ : mode === 'monthly'
126
+ ? monthKey(event.date)
127
+ : event.sessionId ?? `turn:${event.id}`;
128
+ const bucket = groups.get(key) ?? { models: new Map(), events: [] };
129
+ mergeModels(bucket.models, event.model);
130
+ bucket.events.push(event);
131
+ groups.set(key, bucket);
132
+ }
133
+ return [...groups.entries()]
134
+ .sort((a, b) => a[0].localeCompare(b[0]))
135
+ .map(([key, bucket]) => {
136
+ let inputTokens = 0;
137
+ let outputTokens = 0;
138
+ let cacheReadTokens = 0;
139
+ let cacheWriteTokens = 0;
140
+ let costUsd = 0;
141
+ for (const event of bucket.events) {
142
+ inputTokens += event.inputTokens;
143
+ outputTokens += event.outputTokens;
144
+ cacheReadTokens += event.cacheReadTokens;
145
+ cacheWriteTokens += event.cacheWriteTokens;
146
+ costUsd += event.costUsd ?? 0;
147
+ }
148
+ const label = mode === 'session'
149
+ ? key.startsWith('turn:')
150
+ ? `${key.slice(5, 18)}…`
151
+ : key.slice(0, 24)
152
+ : key;
153
+ return {
154
+ key,
155
+ label,
156
+ models: topModels(bucket.models),
157
+ turns: bucket.events.length,
158
+ inputTokens,
159
+ outputTokens,
160
+ cacheReadTokens,
161
+ cacheWriteTokens,
162
+ totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
163
+ costUsd,
164
+ };
165
+ });
166
+ }
167
+ export function formatUsageLedgerHint() {
168
+ return `ดูประวัติ token ทั้งหมด: ${BRAND.cliName} usage daily`;
169
+ }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "sanook-cli",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "description": "A terminal AI coding agent — BYOK, 9 providers, MCP, cron gateway, skills, and git awareness. Built from scratch in TypeScript.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "sanook": "dist/bin.js"
7
+ "sanook": "dist/bin.js",
8
+ "sanookai": "dist/bin.js"
8
9
  },
9
10
  "files": [
10
11
  "dist",
@@ -20,11 +20,11 @@ try {
20
20
  const bold = (s) => paint('1', s);
21
21
 
22
22
  if (isGlobal) {
23
- console.log(`\n${bold('✅ sanook-cli พร้อมใช้')} — พิมพ์ ${cyan('sanook')} เพื่อเริ่ม`);
24
- console.log(dim(' ยังพิมพ์ "sanook" ไม่เจอ? ปิด-เปิด terminal ใหม่ · ตรวจ: ') + cyan('npx sanook doctor') + '\n');
23
+ console.log(`\n${bold('✅ sanook-cli พร้อมใช้')} — พิมพ์ ${cyan('sanook')} หรือ ${cyan('sanookai')} เพื่อเริ่ม`);
24
+ console.log(dim(' Command installed: sanook (alias: sanookai) · ปิด-เปิด terminal ถ้ายังไม่เจอ · ') + cyan('sanook doctor') + '\n');
25
25
  } else {
26
- console.log(`\n${bold('sanook-cli ลงแบบ local แล้ว')} — คำสั่ง ${cyan('sanook')} ยัง${bold('ไม่')}อยู่ใน PATH`);
27
- console.log(` ${dim('• รันเลยตอนนี้:')} ${cyan('npx sanook')}`);
26
+ console.log(`\n${bold('sanook-cli ลงแบบ local แล้ว')} — ${cyan('sanook')} / ${cyan('sanookai')} ยัง${bold('ไม่')}อยู่ใน PATH`);
27
+ console.log(` ${dim('• รันเลยตอนนี้:')} ${cyan('npx sanook')} ${dim('(or')} ${cyan('npx sanookai')}${dim(')')}`);
28
28
  console.log(` ${dim('• ลงให้พิมพ์ sanook ตรงๆ:')} ${cyan('npm install -g sanook-cli')}`);
29
29
  console.log(` ${dim('• ตรวจ/แก้ PATH:')} ${cyan('npx sanook doctor')}\n`);
30
30
  }