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 +21 -0
- package/dist/bin.js +14 -0
- package/dist/brain-link.js +73 -0
- package/dist/brand.js +4 -0
- package/dist/commands.js +4 -1
- package/dist/config.js +35 -29
- package/dist/cost.js +20 -0
- package/dist/gateway/session.js +4 -0
- package/dist/loop.js +31 -4
- package/dist/providers/registry.js +11 -1
- package/dist/session-brain.js +103 -0
- package/dist/ui/app.js +40 -7
- package/dist/ui/render.js +10 -1
- package/dist/usage-cli.js +160 -0
- package/dist/usage-ledger.js +169 -0
- package/package.json +3 -2
- package/scripts/postinstall.mjs +4 -4
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 {
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
220
|
-
const existing = await readJson(
|
|
221
|
-
await writeFile(
|
|
222
|
-
await chmod(
|
|
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(
|
|
227
|
-
const existing = await readJson(
|
|
228
|
-
await writeFile(
|
|
229
|
-
await chmod(
|
|
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(
|
|
239
|
+
return readJson(globalConfigPath());
|
|
234
240
|
}
|
|
235
241
|
/** path ของ auth.json (ใช้โชว์ใน CLI เท่านั้น; ห้าม print raw secret) */
|
|
236
242
|
export function authConfigPath() {
|
|
237
|
-
return
|
|
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(
|
|
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(
|
|
255
|
-
const existing = await readJson(
|
|
256
|
-
await writeFile(
|
|
257
|
-
await chmod(
|
|
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(
|
|
269
|
+
await mkdir(configHomeDir(), { recursive: true });
|
|
264
270
|
const auth = await readStoredAuthRaw();
|
|
265
271
|
auth[envVar] = key;
|
|
266
|
-
await writeFile(
|
|
267
|
-
await chmod(
|
|
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(
|
|
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(
|
|
280
|
-
await chmod(
|
|
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(
|
|
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(
|
|
291
|
-
await chmod(
|
|
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
|
}
|
package/dist/gateway/session.js
CHANGED
|
@@ -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 ===
|
|
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: {
|
|
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
|
-
|
|
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
|
|
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 === '
|
|
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
|
-
|
|
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.
|
|
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",
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -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('
|
|
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 แล้ว')} —
|
|
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
|
}
|