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.
package/dist/ui/render.js CHANGED
@@ -4,6 +4,7 @@ import { render } from 'ink';
4
4
  import { App } from './app.js';
5
5
  import { SetupWizard } from './setup.js';
6
6
  import { BrainWizard } from './brain-wizard.js';
7
+ import { PersonaWizard } from './persona-wizard.js';
7
8
  import { saveKey, saveGlobalConfig, saveBrainPath } from '../config.js';
8
9
  import { BRAND } from '../brand.js';
9
10
  // Ink needs raw mode; mounting on a non-TTY stdin (piped/redirected/cron/CI) throws
@@ -16,6 +17,10 @@ function requireInteractiveTTY() {
16
17
  process.exit(1);
17
18
  }
18
19
  }
20
+ /** locale → ค่า language ที่เก็บลง persona ของ second brain (ขั้นที่ 9) */
21
+ function languageForLocale(locale) {
22
+ return locale === 'en' ? 'English + tech-en' : 'ไทย + tech-en';
23
+ }
19
24
  /**
20
25
  * Root — โฮสต์ setup wizard → brain wizard → REPL ใน **Ink render เดียว**
21
26
  *
@@ -23,10 +28,16 @@ function requireInteractiveTTY() {
23
28
  * พอ instance แรก unmount, stdin raw-mode/keypress listener ไม่ reattach กับ instance ที่ 2
24
29
  * → พิมพ์ในช่องแชทไม่ได้. รวมเป็น tree เดียว (React สลับ component ภายใน) stdin ต่อเนื่องไม่หลุด.
25
30
  */
26
- export function Root({ needsSetup, appProps }) {
31
+ export function Root({ needsSetup, appProps, clearScreen }) {
27
32
  const [phase, setPhase] = useState(needsSetup ? 'setup' : 'app');
28
33
  const [model, setModel] = useState(appProps.initialModel);
29
34
  const [brainNote, setBrainNote] = useState(undefined);
35
+ const [locale, setLocale] = useState('th');
36
+ // เข้า REPL: เคลียร์จอที่เต็มไปด้วย wizard ก่อน → banner "Sanook AI" เด้งบนจอว่าง
37
+ const enterApp = () => {
38
+ clearScreen?.();
39
+ setPhase('app');
40
+ };
30
41
  if (phase === 'setup') {
31
42
  const onComplete = (r) => {
32
43
  void (async () => {
@@ -39,7 +50,11 @@ export function Root({ needsSetup, appProps }) {
39
50
  permissionMode: r.permissionMode,
40
51
  });
41
52
  setModel(r.model);
42
- setPhase(r.createBrain ? 'brain' : 'app');
53
+ setLocale(r.locale);
54
+ if (r.createBrain)
55
+ setPhase('brain');
56
+ else
57
+ enterApp();
43
58
  })();
44
59
  };
45
60
  return _jsx(SetupWizard, { onComplete: onComplete });
@@ -48,25 +63,42 @@ export function Root({ needsSetup, appProps }) {
48
63
  const onComplete = (a) => {
49
64
  void (async () => {
50
65
  const { scaffoldBrain, BRAIN_DEFAULTS, expandHome, wireBrainMcp } = await import('../brain.js');
66
+ const { linkBrainToProject } = await import('../brain-link.js');
67
+ const { seedPersonaMemory } = await import('../memory.js');
51
68
  const today = new Date().toISOString().slice(0, 10);
52
69
  const target = expandHome(a.path);
70
+ const language = languageForLocale(locale);
53
71
  try {
54
72
  const res = await scaffoldBrain(target, {
55
73
  ...BRAIN_DEFAULTS,
56
74
  ownerName: a.ownerName,
57
75
  aiName: a.aiName,
58
76
  autonomy: a.autonomy,
77
+ language,
59
78
  today,
60
79
  });
61
80
  await saveBrainPath(target);
62
81
  const wired = await wireBrainMcp(target).catch(() => 'skip');
82
+ const linked = await linkBrainToProject({ brainPath: target, cwd: process.cwd(), today }).catch(() => null);
83
+ // เซฟ persona/identity ที่เก็บใน wizard ลง durable memory (owner ground-truth) → agent จำได้ทันที
84
+ const seeded = await seedPersonaMemory({
85
+ ownerName: a.ownerName,
86
+ aiName: a.aiName,
87
+ language,
88
+ autonomy: a.autonomy,
89
+ defaults: { ownerName: BRAIN_DEFAULTS.ownerName, aiName: BRAIN_DEFAULTS.aiName },
90
+ }).catch(() => 0);
91
+ const linkNote = linked?.projectRelDir
92
+ ? ` · project ${linked.projectRelDir} · ${linked.memoryCreated ? 'created' : 'linked'} ${BRAND.memoryFileName}`
93
+ : '';
94
+ const memNote = seeded ? ` · จำ persona ${seeded} ข้อ` : '';
63
95
  setBrainNote(`✅ second-brain — ${target} · สร้าง ${res.created.length} ไฟล์ · ` +
64
- `${wired === 'added' ? 'wire filesystem MCP เข้า vault แล้ว' : 'MCP เดิมอยู่แล้ว (ไม่ทับ)'} · เปิดใน Obsidian: Open folder as vault`);
96
+ `${wired === 'added' ? 'wire filesystem MCP เข้า vault แล้ว' : 'MCP เดิมอยู่แล้ว (ไม่ทับ)'}${linkNote}${memNote} · เปิดใน Obsidian: Open folder as vault`);
65
97
  }
66
98
  catch (e) {
67
99
  setBrainNote(`⚠ สร้าง second-brain ไม่สำเร็จ: ${e.message} — ลองใหม่ด้วย ${'`'}sanook brain init${'`'}`);
68
100
  }
69
- setPhase('app');
101
+ enterApp();
70
102
  })();
71
103
  };
72
104
  return _jsx(BrainWizard, { onComplete: onComplete });
@@ -77,7 +109,17 @@ export function Root({ needsSetup, appProps }) {
77
109
  /** เปิดแอป: wizard (ถ้า first-run) → REPL — Ink render ครั้งเดียว (fix: พิมพ์ในช่องแชทไม่ได้) */
78
110
  export function startApp(props) {
79
111
  requireInteractiveTTY();
80
- render(_jsx(Root, { ...props }));
112
+ // background, best-effort: weekly memory + vault consolidation (auto-maintain). Non-blocking so the
113
+ // REPL opens instantly; the consolidated store is ready for the next turn. Runs only when due + enabled.
114
+ void import('../auto-maintain.js').then((m) => m.maybeStartupMaintain()).catch(() => { });
115
+ let instance;
116
+ // \x1b[2J เคลียร์จอ · \x1b[3J เคลียร์ scrollback · \x1b[H cursor กลับมุมซ้ายบน
117
+ // instance.clear() ลบ frame ล่าสุดที่ Ink จำไว้ → App วาดใหม่จากบนสุดไม่เหลือเศษ wizard
118
+ const clearScreen = () => {
119
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
120
+ instance?.clear();
121
+ };
122
+ instance = render(_jsx(Root, { ...props, clearScreen: clearScreen }));
81
123
  }
82
124
  /** เปิด REPL ตรงๆ (ไม่ผ่าน wizard) — เก็บไว้เผื่อ caller อื่น */
83
125
  export function startRepl(appProps) {
@@ -92,6 +134,8 @@ export function startBrainSetup() {
92
134
  const onComplete = (a) => {
93
135
  void (async () => {
94
136
  const { scaffoldBrain, BRAIN_DEFAULTS, expandHome, wireBrainMcp } = await import('../brain.js');
137
+ const { linkBrainToProject } = await import('../brain-link.js');
138
+ const { seedPersonaMemory } = await import('../memory.js');
95
139
  const today = new Date().toISOString().slice(0, 10);
96
140
  const target = expandHome(a.path);
97
141
  const res = await scaffoldBrain(target, {
@@ -103,9 +147,18 @@ export function startBrainSetup() {
103
147
  });
104
148
  await saveBrainPath(target);
105
149
  const wired = await wireBrainMcp(target).catch(() => 'skip');
150
+ const linked = await linkBrainToProject({ brainPath: target, cwd: process.cwd(), today }).catch(() => null);
151
+ await seedPersonaMemory({
152
+ ownerName: a.ownerName,
153
+ aiName: a.aiName,
154
+ autonomy: a.autonomy,
155
+ defaults: { ownerName: BRAIN_DEFAULTS.ownerName, aiName: BRAIN_DEFAULTS.aiName },
156
+ }).catch(() => 0);
106
157
  unmount();
158
+ const linkLine = linked?.projectRelDir ? `\n linked repo → ${linked.projectRelDir} · ${BRAND.memoryFileName} in cwd` : '';
107
159
  process.stdout.write(`\n✅ second-brain — ${target}\n สร้าง ${res.created.length} · ข้าม ${res.skipped.length} (มีอยู่แล้ว ไม่ทับ)` +
108
160
  `\n ${wired === 'added' ? 'wire filesystem MCP เข้า vault แล้ว (agent อ่าน/เขียนได้)' : 'MCP: มี server เดิมอยู่แล้ว (ไม่ทับ)'}` +
161
+ `${linkLine}` +
109
162
  `\n เปิดใน Obsidian: Open folder as vault\n`);
110
163
  resolve();
111
164
  })();
@@ -114,3 +167,32 @@ export function startBrainSetup() {
114
167
  unmount = instance.unmount;
115
168
  });
116
169
  }
170
+ /** standalone `sanook persona` (interactive): ถามชุดคำถาม persona → seed auto-memory + เขียนโปรไฟล์ลง vault */
171
+ export function startPersonaSetup() {
172
+ requireInteractiveTTY();
173
+ return new Promise((resolve) => {
174
+ let unmount = () => { };
175
+ void (async () => {
176
+ const { loadPersonaAnswers, persistPersonaAnswers } = await import('../memory.js');
177
+ const initialAnswers = await loadPersonaAnswers().catch(() => ({}));
178
+ const onComplete = (answers) => {
179
+ void (async () => {
180
+ const { memoryWritten, vaultWritten, brainPath } = await persistPersonaAnswers(answers);
181
+ unmount();
182
+ const memLine = memoryWritten > 0
183
+ ? ` จำเข้า memory แล้ว ${memoryWritten} ข้อ (protected — agent อ่านทุก session)`
184
+ : ` ไม่มีข้อมูลใหม่ที่ต้องจำ (ข้ามหมด/ตรงกับของเดิม)`;
185
+ const vaultLine = vaultWritten
186
+ ? `\n เขียนโปรไฟล์ลง vault → ${brainPath}/Shared/User-Persona/persona.md`
187
+ : brainPath
188
+ ? `\n ⚠ ข้ามการเขียนโปรไฟล์ลง vault (ไม่พบโฟลเดอร์ Shared/User-Persona — รัน \`${BRAND.cliName} brain init\` เพื่อ scaffold ใหม่)`
189
+ : `\n (ยังไม่มี second-brain — รัน \`${BRAND.cliName} brain init\` เพื่อเก็บโปรไฟล์ลง vault ด้วย)`;
190
+ process.stdout.write(`\n✅ บันทึก Persona เรียบร้อย\n${memLine}${vaultLine}\n`);
191
+ resolve();
192
+ })();
193
+ };
194
+ const instance = render(_jsx(PersonaWizard, { onComplete: onComplete, initialAnswers: initialAnswers }));
195
+ unmount = instance.unmount;
196
+ })();
197
+ });
198
+ }
@@ -0,0 +1,118 @@
1
+ // ============================================================================
2
+ // src/ui/tool-activity.ts — turns a tool call's INPUT into a human-friendly activity:
3
+ // a clear one-line title ("แก้ไฟล์ src/app.tsx", "$ npm test") plus, for code edits, a
4
+ // colored diff (green + additions, red - deletions). Computed at tool-call time so the
5
+ // REPL shows in detail what the agent is doing in real time — not after the fact.
6
+ // ============================================================================
7
+ import { editDiffSegments } from '../diff.js';
8
+ // PER-SIDE diff cap (removed and added counted separately) — a two-sided edit can still yield up to
9
+ // ~2×this rows, so the overall per-row height bound is enforced by ActivityRow (MAX_ROW_DIFF_LINES).
10
+ // diffLines/additionLines append a correct "…(+N บรรทัด)" sentinel per side when exceeded.
11
+ const MAX_DIFF_LINES = 10;
12
+ function str(v) {
13
+ return typeof v === 'string' ? v : '';
14
+ }
15
+ function basenameish(p) {
16
+ return p.length > 52 ? `…${p.slice(-51)}` : p;
17
+ }
18
+ /** structured edit diff (old → new) with common prefix/suffix trimmed — green +, red -.
19
+ * Built on the shared core in src/diff.ts so the algorithm doesn't drift from renderEditDiff. */
20
+ export function diffLines(oldStr, newStr, max = MAX_DIFF_LINES) {
21
+ const seg = editDiffSegments(oldStr, newStr, max);
22
+ const out = [];
23
+ for (const l of seg.removed)
24
+ out.push({ sign: '-', text: l });
25
+ if (seg.moreRemoved)
26
+ out.push({ sign: ' ', text: `…(-${seg.moreRemoved} บรรทัด)` });
27
+ for (const l of seg.added)
28
+ out.push({ sign: '+', text: l });
29
+ if (seg.moreAdded)
30
+ out.push({ sign: ' ', text: `…(+${seg.moreAdded} บรรทัด)` });
31
+ return out;
32
+ }
33
+ /** whole-content as additions (new file write) — all green. Drops the trailing empty line of a
34
+ * file ending in \n so the green-line count matches the title's countLines() (no spurious blank). */
35
+ function additionLines(content, max = MAX_DIFF_LINES) {
36
+ if (content === '')
37
+ return []; // empty write → no diff body (title already shows "+0 บรรทัด")
38
+ const all = content.split('\n');
39
+ if (content.endsWith('\n'))
40
+ all.pop();
41
+ const out = all.slice(0, max).map((text) => ({ sign: '+', text }));
42
+ if (all.length > max)
43
+ out.push({ sign: ' ', text: `…(+${all.length - max} บรรทัด)` });
44
+ return out;
45
+ }
46
+ function countLines(s) {
47
+ if (s === '')
48
+ return 0;
49
+ const lines = s.split('\n');
50
+ if (s.endsWith('\n'))
51
+ lines.pop();
52
+ return lines.length;
53
+ }
54
+ /** map a tool name + its input into a friendly title (+ optional colored diff) */
55
+ export function describeToolCall(name, input) {
56
+ const i = (input ?? {});
57
+ switch (name) {
58
+ case 'edit_file': {
59
+ const path = basenameish(str(i.path));
60
+ const all = i.replace_all ? ' (ทุกที่)' : '';
61
+ return { title: `✎ แก้ไฟล์ ${path}${all}`, diff: diffLines(str(i.old_string), str(i.new_string)) };
62
+ }
63
+ case 'write_file': {
64
+ const path = basenameish(str(i.path));
65
+ const content = str(i.content);
66
+ return { title: `✚ เขียนไฟล์ ${path} (+${countLines(content)} บรรทัด)`, diff: additionLines(content) };
67
+ }
68
+ case 'run_bash':
69
+ return { title: `$ ${str(i.cmd)}` };
70
+ case 'run_python':
71
+ return { title: i.path ? `▶ python ${str(i.path)}` : `▶ รัน python (${str(i.code).length} ตัวอักษร)` };
72
+ case 'run_rust':
73
+ return { title: i.path ? `▶ rust ${str(i.path)}` : `▶ รัน rust (${str(i.code).length} ตัวอักษร)` };
74
+ case 'read_file':
75
+ return { title: `📖 อ่านไฟล์ ${basenameish(str(i.path))}` };
76
+ case 'list_dir':
77
+ return { title: `📁 ดูโฟลเดอร์ ${basenameish(str(i.path) || '.')}` };
78
+ case 'glob':
79
+ return { title: `🔎 ค้นไฟล์ ${str(i.pattern)}` };
80
+ case 'grep':
81
+ return { title: `🔎 ค้นโค้ด "${str(i.pattern)}"` };
82
+ case 'git_status':
83
+ return { title: 'git status' };
84
+ case 'git_diff':
85
+ return { title: 'git diff' };
86
+ case 'git_log':
87
+ return { title: 'git log' };
88
+ case 'git_commit':
89
+ return { title: `⎇ git commit -m "${str(i.message).slice(0, 60)}"` };
90
+ case 'remember':
91
+ return { title: `🧠 จำ: ${str(i.fact).slice(0, 60)}` };
92
+ case 'recall':
93
+ return { title: `🧠 ค้นความจำ "${str(i.query)}"` };
94
+ case 'create_skill':
95
+ return { title: `✨ สร้าง skill ${str(i.name)}` };
96
+ case 'find_skills':
97
+ return { title: `✨ หา skill "${str(i.query)}"` };
98
+ case 'skill':
99
+ return { title: `✨ เปิด skill ${str(i.name)}` };
100
+ case 'web_fetch':
101
+ return { title: `🌐 โหลด ${str(i.url).slice(0, 60)}` };
102
+ case 'task':
103
+ return { title: `🤖 มอบงานให้ sub-agent: ${str(i.prompt || i.task).slice(0, 50)}` };
104
+ case 'schedule_task':
105
+ return { title: `⏰ ตั้งเวลา: ${str(i.when)} → ${str(i.task).slice(0, 40)}` };
106
+ default: {
107
+ const detail = pickDetail(i);
108
+ return { title: detail ? `${name} ${detail}` : name };
109
+ }
110
+ }
111
+ }
112
+ function pickDetail(i) {
113
+ for (const key of ['path', 'query', 'pattern', 'name', 'url', 'id']) {
114
+ if (typeof i[key] === 'string' && i[key])
115
+ return String(i[key]).slice(0, 60);
116
+ }
117
+ return '';
118
+ }
@@ -1,4 +1,5 @@
1
1
  import { inspect } from 'node:util';
2
+ import { describeToolCall } from './tool-activity.js';
2
3
  export const TOOL_TRAIL_LIMIT = 6;
3
4
  function clip(text, width) {
4
5
  if (width <= 0)
@@ -40,7 +41,10 @@ export function updateToolTrailOnEvent(items, event, nextId) {
40
41
  if (event.type === 'tool-call') {
41
42
  const name = event.tool?.trim() || 'tool';
42
43
  return {
43
- items: trimItems([...items, { detail: compactToolDetail(event.detail), id: nextId, name, status: 'running' }]),
44
+ items: trimItems([
45
+ ...items,
46
+ { detail: compactToolDetail(event.detail), id: nextId, name, status: 'running', activity: describeToolCall(name, event.detail) },
47
+ ]),
44
48
  nextId: nextId + 1,
45
49
  };
46
50
  }
@@ -75,15 +79,23 @@ function statusSummary(items) {
75
79
  const error = items.filter((item) => item.status === 'error').length;
76
80
  return [`${done} done`, running ? `${running} running` : '', error ? `${error} error` : ''].filter(Boolean).join(' / ');
77
81
  }
82
+ /** the 2 header lines (title + view/status meta) shared by string + rich rendering */
83
+ export function toolTrailHeader(items, mode) {
84
+ return [`Sanook tool trail (${items.length})`, `view: ${mode} | ${statusSummary(items)} | Ctrl+T / /trail`];
85
+ }
86
+ /** width budget for a rich activity row (mirrors toolTrailLines clipping) */
87
+ export function toolTrailWidth(columns) {
88
+ return Math.max(24, Math.min(Math.max(30, columns - 4), 96));
89
+ }
78
90
  export function toolTrailLines(items, columns, mode = 'expanded') {
79
91
  if (mode === 'hidden')
80
92
  return [];
81
93
  if (!items.length)
82
94
  return [];
83
- const width = Math.max(24, Math.min(Math.max(30, columns - 4), 96));
95
+ const width = toolTrailWidth(columns);
84
96
  const nameWidth = Math.max(8, Math.min(24, Math.floor(width * 0.34)));
85
97
  const detailWidth = Math.max(0, width - nameWidth - 18);
86
- const lines = [`Sanook tool trail (${items.length})`, `view: ${mode} | ${statusSummary(items)} | Ctrl+T / /trail`];
98
+ const lines = toolTrailHeader(items, mode);
87
99
  if (mode === 'compact') {
88
100
  lines.push(`tools: ${items.map((item) => `${markerForStatus(item.status)}${item.name}`).join(' ')}`);
89
101
  return lines.map((line) => clip(line, width));
@@ -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
+ }