throughline 0.3.0 → 0.3.1
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/bin/throughline.mjs +1 -1
- package/package.json +1 -1
- package/src/state-file.mjs +44 -7
- package/src/state-file.test.mjs +135 -0
- package/src/token-monitor.mjs +280 -62
- package/src/token-monitor.test.mjs +331 -0
- package/src/transcript-usage.mjs +27 -6
- package/src/transcript-usage.test.mjs +159 -0
package/bin/throughline.mjs
CHANGED
|
@@ -36,7 +36,7 @@ switch (cmd) {
|
|
|
36
36
|
await import('../src/prompt-submit.mjs');
|
|
37
37
|
break;
|
|
38
38
|
case 'monitor':
|
|
39
|
-
await import('../src/token-monitor.mjs');
|
|
39
|
+
(await import('../src/token-monitor.mjs')).main();
|
|
40
40
|
break;
|
|
41
41
|
case 'detail':
|
|
42
42
|
(await import('../src/sc-detail.mjs')).run(rest);
|
package/package.json
CHANGED
package/src/state-file.mjs
CHANGED
|
@@ -68,25 +68,52 @@ export const STALE_DELETE_MS = 24 * 60 * 60 * 1000; // 24 時間: ファイル
|
|
|
68
68
|
export function readAllSessionStates() {
|
|
69
69
|
if (!existsSync(STATE_DIR)) return [];
|
|
70
70
|
const now = Date.now();
|
|
71
|
-
|
|
71
|
+
let entries;
|
|
72
|
+
try {
|
|
73
|
+
entries = readdirSync(STATE_DIR);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (err.code === 'ENOENT') return [];
|
|
76
|
+
process.stderr.write(`[state-file] readdir failed (${err.code ?? 'unknown'}): ${err.message}\n`);
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
72
79
|
const results = [];
|
|
73
80
|
for (const name of entries) {
|
|
74
81
|
if (!name.endsWith('.json')) continue;
|
|
75
82
|
const file = join(STATE_DIR, name);
|
|
76
|
-
|
|
83
|
+
|
|
84
|
+
// 常駐 monitor を落とさないため IO 例外は吸収する。
|
|
85
|
+
// ENOENT: 削除 race、EACCES/EPERM: 一時的権限問題、いずれも skip して次フレームで再試行
|
|
86
|
+
let raw;
|
|
87
|
+
try {
|
|
88
|
+
raw = readFileSync(file, 'utf8');
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err.code !== 'ENOENT') {
|
|
91
|
+
process.stderr.write(`[state-file] read failed ${name} (${err.code ?? 'unknown'}): ${err.message}\n`);
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
77
96
|
let parsed;
|
|
78
97
|
try {
|
|
79
98
|
parsed = JSON.parse(raw);
|
|
80
99
|
} catch (err) {
|
|
81
100
|
// JSON 破損は状態ファイル固有の冪等な状況(次ターンで再生成される)
|
|
82
101
|
process.stderr.write(`[state-file] corrupt state file ${name}, deleting: ${err.message}\n`);
|
|
83
|
-
|
|
102
|
+
try {
|
|
103
|
+
unlinkSync(file);
|
|
104
|
+
} catch {
|
|
105
|
+
// 削除失敗も握りつぶす(次回削除される)
|
|
106
|
+
}
|
|
84
107
|
continue;
|
|
85
108
|
}
|
|
86
109
|
const age = now - (parsed.updatedAt ?? 0);
|
|
87
110
|
if (age > STALE_DELETE_MS) {
|
|
88
111
|
// 24h 超: ハード削除(無制限蓄積防止)
|
|
89
|
-
|
|
112
|
+
try {
|
|
113
|
+
unlinkSync(file);
|
|
114
|
+
} catch {
|
|
115
|
+
// 削除失敗は致命ではない、次回再試行
|
|
116
|
+
}
|
|
90
117
|
continue;
|
|
91
118
|
}
|
|
92
119
|
parsed.stale = age > STALE_HIDE_MS;
|
|
@@ -102,15 +129,25 @@ export function readAllSessionStates() {
|
|
|
102
129
|
export function snapshotStateMtimes() {
|
|
103
130
|
const result = new Map();
|
|
104
131
|
if (!existsSync(STATE_DIR)) return result;
|
|
105
|
-
|
|
132
|
+
let entries;
|
|
133
|
+
try {
|
|
134
|
+
entries = readdirSync(STATE_DIR);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (err.code === 'ENOENT') return result;
|
|
137
|
+
process.stderr.write(`[state-file] snapshot readdir failed (${err.code ?? 'unknown'}): ${err.message}\n`);
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
for (const name of entries) {
|
|
106
141
|
if (!name.endsWith('.json')) continue;
|
|
107
142
|
// readdir と stat の間でファイルが削除される race がある。
|
|
108
|
-
// ENOENT
|
|
143
|
+
// monitor を落とさないため ENOENT 以外の IO 例外も吸収する(EACCES/EPERM 等は一時的で次フレームで回復)
|
|
109
144
|
const file = join(STATE_DIR, name);
|
|
110
145
|
try {
|
|
111
146
|
result.set(name, statSync(file).mtimeMs);
|
|
112
147
|
} catch (err) {
|
|
113
|
-
if (err.code !== 'ENOENT')
|
|
148
|
+
if (err.code !== 'ENOENT') {
|
|
149
|
+
process.stderr.write(`[state-file] stat failed ${name} (${err.code ?? 'unknown'}): ${err.message}\n`);
|
|
150
|
+
}
|
|
114
151
|
}
|
|
115
152
|
}
|
|
116
153
|
return result;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, chmodSync, existsSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir, homedir, platform } from 'node:os';
|
|
6
|
+
|
|
7
|
+
import { normalizeProjectPath } from './state-file.mjs';
|
|
8
|
+
|
|
9
|
+
// state-file.mjs は ~/.throughline/state を直接見るので、モジュール全体を一時ディレクトリ指定で
|
|
10
|
+
// 差し替える薄いヘルパーを用意する。HOME 環境変数を偽装して import し直す方式で隔離する。
|
|
11
|
+
async function withIsolatedStateDir(testFn) {
|
|
12
|
+
const work = mkdtempSync(join(tmpdir(), 'tl-state-'));
|
|
13
|
+
const origHome = process.env.HOME;
|
|
14
|
+
const origUserProfile = process.env.USERPROFILE;
|
|
15
|
+
process.env.HOME = work;
|
|
16
|
+
process.env.USERPROFILE = work;
|
|
17
|
+
// ESM キャッシュをバイパスするため query string 付きで import
|
|
18
|
+
const mod = await import(`./state-file.mjs?isolated=${Date.now()}-${Math.random()}`);
|
|
19
|
+
const stateDir = mod.getStateDir();
|
|
20
|
+
mkdirSync(stateDir, { recursive: true });
|
|
21
|
+
try {
|
|
22
|
+
await testFn({ stateDir, mod });
|
|
23
|
+
} finally {
|
|
24
|
+
process.env.HOME = origHome;
|
|
25
|
+
process.env.USERPROFILE = origUserProfile;
|
|
26
|
+
rmSync(work, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('normalizeProjectPath: 空文字列は空文字列', () => {
|
|
31
|
+
assert.equal(normalizeProjectPath(''), '');
|
|
32
|
+
assert.equal(normalizeProjectPath(null), '');
|
|
33
|
+
assert.equal(normalizeProjectPath(undefined), '');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('normalizeProjectPath: バックスラッシュがスラッシュになり末尾スラッシュ除去', () => {
|
|
37
|
+
const result = normalizeProjectPath('C:\\Users\\foo\\');
|
|
38
|
+
assert.ok(result.includes('/'));
|
|
39
|
+
assert.ok(!result.endsWith('/') || result === '/');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('normalizeProjectPath: Windows では lowercase', () => {
|
|
43
|
+
if (platform() !== 'win32') return;
|
|
44
|
+
const result = normalizeProjectPath('C:\\Users\\Foo');
|
|
45
|
+
assert.equal(result, result.toLowerCase());
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('readAllSessionStates: 破損 JSON を削除して skip する', async () => {
|
|
49
|
+
await withIsolatedStateDir(async ({ stateDir, mod }) => {
|
|
50
|
+
const broken = join(stateDir, 'broken-abc.json');
|
|
51
|
+
const good = join(stateDir, 'good-session.json');
|
|
52
|
+
writeFileSync(broken, '{ not valid json');
|
|
53
|
+
writeFileSync(good, JSON.stringify({
|
|
54
|
+
sessionId: 'good-session',
|
|
55
|
+
projectPath: '/tmp/foo',
|
|
56
|
+
transcriptPath: null,
|
|
57
|
+
updatedAt: Date.now(),
|
|
58
|
+
}));
|
|
59
|
+
const results = mod.readAllSessionStates();
|
|
60
|
+
assert.equal(results.length, 1);
|
|
61
|
+
assert.equal(results[0].sessionId, 'good-session');
|
|
62
|
+
// 破損ファイルは削除されている
|
|
63
|
+
assert.ok(!existsSync(broken), 'corrupt file should be unlinked');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('readAllSessionStates: 24h 超のファイルはハード削除される', async () => {
|
|
68
|
+
await withIsolatedStateDir(async ({ stateDir, mod }) => {
|
|
69
|
+
const old = join(stateDir, 'old-session.json');
|
|
70
|
+
const past = Date.now() - (25 * 60 * 60 * 1000); // 25h 前
|
|
71
|
+
writeFileSync(old, JSON.stringify({
|
|
72
|
+
sessionId: 'old-session',
|
|
73
|
+
projectPath: '/tmp/foo',
|
|
74
|
+
transcriptPath: null,
|
|
75
|
+
updatedAt: past,
|
|
76
|
+
}));
|
|
77
|
+
const results = mod.readAllSessionStates();
|
|
78
|
+
assert.equal(results.length, 0);
|
|
79
|
+
assert.ok(!existsSync(old), 'old file should be deleted');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('readAllSessionStates: 15 分超は stale フラグ付きで返される', async () => {
|
|
84
|
+
await withIsolatedStateDir(async ({ stateDir, mod }) => {
|
|
85
|
+
const stale = join(stateDir, 'stale-session.json');
|
|
86
|
+
const past = Date.now() - (20 * 60 * 1000); // 20 分前
|
|
87
|
+
writeFileSync(stale, JSON.stringify({
|
|
88
|
+
sessionId: 'stale-session',
|
|
89
|
+
projectPath: '/tmp/foo',
|
|
90
|
+
transcriptPath: null,
|
|
91
|
+
updatedAt: past,
|
|
92
|
+
}));
|
|
93
|
+
const results = mod.readAllSessionStates();
|
|
94
|
+
assert.equal(results.length, 1);
|
|
95
|
+
assert.equal(results[0].stale, true);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('readAllSessionStates: state ディレクトリ未作成なら空配列', async () => {
|
|
100
|
+
await withIsolatedStateDir(async ({ stateDir, mod }) => {
|
|
101
|
+
rmSync(stateDir, { recursive: true, force: true });
|
|
102
|
+
const results = mod.readAllSessionStates();
|
|
103
|
+
assert.deepEqual(results, []);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('snapshotStateMtimes: 存在する JSON の mtime を返す', async () => {
|
|
108
|
+
await withIsolatedStateDir(async ({ stateDir, mod }) => {
|
|
109
|
+
const file = join(stateDir, 'abc.json');
|
|
110
|
+
writeFileSync(file, '{}');
|
|
111
|
+
const snap = mod.snapshotStateMtimes();
|
|
112
|
+
assert.equal(snap.size, 1);
|
|
113
|
+
assert.ok(snap.has('abc.json'));
|
|
114
|
+
assert.ok(typeof snap.get('abc.json') === 'number');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('snapshotStateMtimes: .json 以外は無視', async () => {
|
|
119
|
+
await withIsolatedStateDir(async ({ stateDir, mod }) => {
|
|
120
|
+
writeFileSync(join(stateDir, 'ignored.txt'), 'hello');
|
|
121
|
+
writeFileSync(join(stateDir, 'abc.json'), '{}');
|
|
122
|
+
const snap = mod.snapshotStateMtimes();
|
|
123
|
+
assert.equal(snap.size, 1);
|
|
124
|
+
assert.ok(snap.has('abc.json'));
|
|
125
|
+
assert.ok(!snap.has('ignored.txt'));
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('snapshotStateMtimes: ディレクトリ未作成なら空 Map', async () => {
|
|
130
|
+
await withIsolatedStateDir(async ({ stateDir, mod }) => {
|
|
131
|
+
rmSync(stateDir, { recursive: true, force: true });
|
|
132
|
+
const snap = mod.snapshotStateMtimes();
|
|
133
|
+
assert.equal(snap.size, 0);
|
|
134
|
+
});
|
|
135
|
+
});
|
package/src/token-monitor.mjs
CHANGED
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { basename } from 'node:path';
|
|
21
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
22
|
+
import { statSync, existsSync } from 'node:fs';
|
|
21
23
|
import { getStateDir, readAllSessionStates, snapshotStateMtimes, normalizeProjectPath } from './state-file.mjs';
|
|
22
24
|
import { readLatestUsage } from './transcript-usage.mjs';
|
|
23
|
-
import { stripAnsi } from './transcript-reader.mjs';
|
|
24
25
|
|
|
25
26
|
const REFRESH_MS = 1000;
|
|
26
27
|
|
|
@@ -45,60 +46,156 @@ function color(c, text) {
|
|
|
45
46
|
return `${c}${text}${ANSI.reset}`;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
// --- セル幅計算(ANSI / CJK / 絵文字対応、依存ゼロ) ---
|
|
50
|
+
//
|
|
51
|
+
// Node v22 には util.getStringWidth がないため、主要な East Asian Wide + 絵文字ブロックを
|
|
52
|
+
// 自前の範囲判定で 2 セルとして扱う。East Asian Ambiguous は 1 セル(アラビア・タイ等は
|
|
53
|
+
// 既存の char-count 実装と同じ扱いなので悪化しない)。
|
|
54
|
+
// ZWJ / VS16 は幅 0 として扱う(絵文字シーケンスの微ズレは許容)。
|
|
55
|
+
const ZERO_WIDTH_CODEPOINTS = new Set([0x200b, 0x200c, 0x200d, 0xfeff, 0xfe0e, 0xfe0f]);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 単一コードポイントのセル幅を返す(0 / 1 / 2)
|
|
59
|
+
* @param {number} cp
|
|
60
|
+
*/
|
|
61
|
+
function codePointWidth(cp) {
|
|
62
|
+
if (cp < 0x20) return 0; // C0 制御
|
|
63
|
+
if (cp >= 0x7f && cp < 0xa0) return 0; // DEL / C1 制御
|
|
64
|
+
if (ZERO_WIDTH_CODEPOINTS.has(cp)) return 0;
|
|
65
|
+
// Combining marks (簡易: Combining Diacritical Marks 主要ブロック)
|
|
66
|
+
if (cp >= 0x0300 && cp <= 0x036f) return 0;
|
|
67
|
+
// Wide ranges
|
|
68
|
+
if (
|
|
69
|
+
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
|
70
|
+
(cp >= 0x2e80 && cp <= 0x303e) || // CJK Radicals / Kangxi
|
|
71
|
+
(cp >= 0x3041 && cp <= 0x33ff) || // Hiragana/Katakana/Bopomofo/CJK Symbols
|
|
72
|
+
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Ext A
|
|
73
|
+
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified
|
|
74
|
+
(cp >= 0xa000 && cp <= 0xa4cf) || // Yi
|
|
75
|
+
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
|
|
76
|
+
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compat Ideographs
|
|
77
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) || // CJK Compat Forms
|
|
78
|
+
(cp >= 0xff00 && cp <= 0xff60) || // Fullwidth
|
|
79
|
+
(cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
|
|
80
|
+
(cp >= 0x1f300 && cp <= 0x1faff) || // 絵文字主要ブロック
|
|
81
|
+
(cp >= 0x20000 && cp <= 0x3134f) // CJK Ext B-G
|
|
82
|
+
) {
|
|
83
|
+
return 2;
|
|
84
|
+
}
|
|
85
|
+
return 1;
|
|
51
86
|
}
|
|
52
87
|
|
|
53
88
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
89
|
+
* 文字列のセル幅合計(ANSI 剥ぎ取り済み前提ではない — 内部で剥ぐ)
|
|
90
|
+
* @param {string} s
|
|
91
|
+
*/
|
|
92
|
+
function cellWidth(s) {
|
|
93
|
+
if (typeof s !== 'string') return 0;
|
|
94
|
+
const stripped = stripVTControlCharacters(s);
|
|
95
|
+
let total = 0;
|
|
96
|
+
for (const ch of stripped) {
|
|
97
|
+
total += codePointWidth(ch.codePointAt(0));
|
|
98
|
+
}
|
|
99
|
+
return total;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 行をセル幅で切り詰める。ANSI コードはそのまま通過させ(幅 0)、
|
|
104
|
+
* 可視セル幅が maxCells に達したら残りを捨てて reset を付けて返す。
|
|
105
|
+
* CJK 文字を跨ぐときは 1 セル余る場合があるので空白で埋める。
|
|
56
106
|
* @param {string} line
|
|
57
|
-
* @param {number}
|
|
107
|
+
* @param {number} maxCells
|
|
58
108
|
*/
|
|
59
|
-
function
|
|
60
|
-
if (
|
|
61
|
-
if (
|
|
109
|
+
function truncateToCells(line, maxCells) {
|
|
110
|
+
if (maxCells <= 0) return '';
|
|
111
|
+
if (cellWidth(line) <= maxCells) return line;
|
|
62
112
|
let out = '';
|
|
63
|
-
let
|
|
113
|
+
let cells = 0;
|
|
64
114
|
let i = 0;
|
|
65
|
-
while (i < line.length
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
115
|
+
while (i < line.length) {
|
|
116
|
+
const code = line.charCodeAt(i);
|
|
117
|
+
// ANSI CSI: \x1b[ ... 終端 (0x40-0x7e)
|
|
118
|
+
if (code === 0x1b && line.charCodeAt(i + 1) === 0x5b) {
|
|
119
|
+
let j = i + 2;
|
|
120
|
+
while (j < line.length) {
|
|
121
|
+
const c = line.charCodeAt(j);
|
|
122
|
+
if (c >= 0x40 && c <= 0x7e) { j++; break; }
|
|
123
|
+
j++;
|
|
124
|
+
}
|
|
125
|
+
out += line.slice(i, j);
|
|
126
|
+
i = j;
|
|
73
127
|
continue;
|
|
74
128
|
}
|
|
129
|
+
// コードポイント単位で取得(サロゲートペア考慮)
|
|
130
|
+
const cp = line.codePointAt(i);
|
|
131
|
+
const ch = String.fromCodePoint(cp);
|
|
132
|
+
const w = codePointWidth(cp);
|
|
133
|
+
if (cells + w > maxCells) {
|
|
134
|
+
// CJK 1 セル余り
|
|
135
|
+
if (cells < maxCells) out += ' ';
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
75
138
|
out += ch;
|
|
76
|
-
|
|
77
|
-
i
|
|
139
|
+
cells += w;
|
|
140
|
+
i += ch.length;
|
|
78
141
|
}
|
|
79
142
|
return out + ANSI.reset;
|
|
80
143
|
}
|
|
81
144
|
|
|
145
|
+
/**
|
|
146
|
+
* 文字列の末尾を半角スペースでパディングして targetCells に揃える。
|
|
147
|
+
* 既にはみ出している場合は truncateToCells で切り詰める。
|
|
148
|
+
* @param {string} s
|
|
149
|
+
* @param {number} targetCells
|
|
150
|
+
*/
|
|
151
|
+
function padCellsEnd(s, targetCells) {
|
|
152
|
+
const width = cellWidth(s);
|
|
153
|
+
if (width === targetCells) return s;
|
|
154
|
+
if (width > targetCells) return truncateToCells(s, targetCells);
|
|
155
|
+
return s + ' '.repeat(targetCells - width);
|
|
156
|
+
}
|
|
157
|
+
|
|
82
158
|
// --- CLI 引数 ---
|
|
159
|
+
/**
|
|
160
|
+
* @param {string[]} argv
|
|
161
|
+
* @returns {{all: boolean, session: string|null}}
|
|
162
|
+
* @throws {Error} --session に値が欠落している場合
|
|
163
|
+
*/
|
|
83
164
|
function parseArgs(argv) {
|
|
84
165
|
const args = { all: false, session: null };
|
|
85
166
|
for (let i = 0; i < argv.length; i++) {
|
|
86
|
-
if (argv[i] === '--all')
|
|
87
|
-
|
|
167
|
+
if (argv[i] === '--all') {
|
|
168
|
+
args.all = true;
|
|
169
|
+
} else if (argv[i] === '--session') {
|
|
170
|
+
const value = argv[i + 1];
|
|
171
|
+
if (value === undefined || value.startsWith('--')) {
|
|
172
|
+
throw new Error('--session requires a session id (or prefix) as next argument');
|
|
173
|
+
}
|
|
174
|
+
args.session = value;
|
|
175
|
+
i++;
|
|
176
|
+
}
|
|
88
177
|
}
|
|
89
178
|
return args;
|
|
90
179
|
}
|
|
91
180
|
|
|
92
181
|
// --- 表示 ---
|
|
93
182
|
function renderBar(ratio, width = 20) {
|
|
94
|
-
|
|
183
|
+
// NaN は 0、+Infinity は 1(オーバーフロー = 満タン表示)、負値 / -Infinity は 0 にクランプ
|
|
184
|
+
let safe;
|
|
185
|
+
if (Number.isNaN(ratio)) safe = 0;
|
|
186
|
+
else if (ratio === Infinity) safe = 1;
|
|
187
|
+
else if (ratio === -Infinity) safe = 0;
|
|
188
|
+
else safe = Math.max(0, Math.min(1, ratio));
|
|
189
|
+
const filled = Math.round(safe * width);
|
|
95
190
|
return '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
96
191
|
}
|
|
97
192
|
|
|
98
193
|
function formatNumber(n) {
|
|
99
|
-
if (n
|
|
194
|
+
if (!Number.isFinite(n) || n < 0) return '0';
|
|
195
|
+
// 999_950 以上は toFixed(1) で "1000.0k" になってしまうので M 表記に切り上げる
|
|
196
|
+
if (n >= 999_500) return (n / 1_000_000).toFixed(2) + 'M';
|
|
100
197
|
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';
|
|
101
|
-
return String(n);
|
|
198
|
+
return String(Math.floor(n));
|
|
102
199
|
}
|
|
103
200
|
|
|
104
201
|
function formatLine({ state, usage, isActive }) {
|
|
@@ -116,13 +213,16 @@ function formatLine({ state, usage, isActive }) {
|
|
|
116
213
|
ratio >= 0.7 ? ANSI.yellow :
|
|
117
214
|
ANSI.green;
|
|
118
215
|
|
|
216
|
+
// 色覚配慮: 色だけでなく記号 / テキスト接頭辞でも重要度を示す
|
|
217
|
+
// 90%超: !! + 強い文言 (赤)
|
|
218
|
+
// 70%超: ! + 弱い文言 (黄)
|
|
119
219
|
const warn =
|
|
120
|
-
ratio >= 0.9 ? color(ANSI.red, '
|
|
121
|
-
ratio >= 0.7 ? color(ANSI.yellow, '
|
|
220
|
+
ratio >= 0.9 ? color(ANSI.red + ANSI.bold, ' !! /clear 強く推奨') :
|
|
221
|
+
ratio >= 0.7 ? color(ANSI.yellow, ' ! そろそろ /clear') :
|
|
122
222
|
'';
|
|
123
223
|
|
|
124
224
|
const marker = isActive ? color(ANSI.bold + ANSI.cyan, '▶') : ' ';
|
|
125
|
-
const projectCol = project
|
|
225
|
+
const projectCol = padCellsEnd(project, 18);
|
|
126
226
|
const idCol = color(ANSI.dim, shortId);
|
|
127
227
|
const barCol = color(barColor, bar);
|
|
128
228
|
const tokCol = `${formatNumber(tokens).padStart(6)} / ${pct.toString().padStart(3)}%`;
|
|
@@ -133,11 +233,18 @@ function formatLine({ state, usage, isActive }) {
|
|
|
133
233
|
}
|
|
134
234
|
|
|
135
235
|
// --- フィルタ ---
|
|
236
|
+
/**
|
|
237
|
+
* セッション一覧に表示フィルタを適用する。
|
|
238
|
+
* ルール:
|
|
239
|
+
* - stale (15 分無活動) は基本非表示。--all のときのみ stale も含める
|
|
240
|
+
* - --session が指定されればプレフィックス一致、ただし base (stale フィルタ通過済み) 上で絞る
|
|
241
|
+
* - --session 無し & --all 無しのときは cwd 一致のみ残す
|
|
242
|
+
*/
|
|
136
243
|
function filterStates(states, args, cwd) {
|
|
137
|
-
|
|
138
|
-
let base = args.all ? states : states.filter((s) => !s.stale);
|
|
244
|
+
const base = args.all ? states : states.filter((s) => !s.stale);
|
|
139
245
|
if (args.session) {
|
|
140
|
-
|
|
246
|
+
// startsWith が完全一致も含むので === は冗長
|
|
247
|
+
return base.filter((s) => s.sessionId.startsWith(args.session));
|
|
141
248
|
}
|
|
142
249
|
if (args.all) return base;
|
|
143
250
|
const normalizedCwd = normalizeProjectPath(cwd);
|
|
@@ -146,24 +253,55 @@ function filterStates(states, args, cwd) {
|
|
|
146
253
|
|
|
147
254
|
// --- メインループ ---
|
|
148
255
|
let lastRenderedLines = 0;
|
|
149
|
-
let
|
|
256
|
+
let lastRenderKey = '';
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 再描画要否の判定キー。state ファイル群の mtime と transcript JSONL の size を
|
|
260
|
+
* 1 本の文字列にまとめてハッシュキーとする。キーが前回と同じなら描画スキップ。
|
|
261
|
+
*
|
|
262
|
+
* 注: transcript は JSONL append-only なので size 変化 = 新しい usage エントリ到来と
|
|
263
|
+
* 同義。mtime だけでは transcript 更新を検出できない(state-file の mtime は
|
|
264
|
+
* Stop hook のタイミングで更新され、transcript は Claude の stream 中に太る)。
|
|
265
|
+
*/
|
|
266
|
+
function computeRenderKey() {
|
|
267
|
+
const parts = [];
|
|
268
|
+
// state mtimes
|
|
269
|
+
const mtimes = snapshotStateMtimes();
|
|
270
|
+
const names = Array.from(mtimes.keys()).sort();
|
|
271
|
+
for (const name of names) parts.push(`s:${name}:${mtimes.get(name)}`);
|
|
272
|
+
// transcript sizes(state ファイルを読まずに直接 stat、IO 最小化)
|
|
273
|
+
try {
|
|
274
|
+
const states = readAllSessionStates();
|
|
275
|
+
for (const st of states) {
|
|
276
|
+
if (!st.transcriptPath || !existsSync(st.transcriptPath)) continue;
|
|
277
|
+
try {
|
|
278
|
+
const size = statSync(st.transcriptPath).size;
|
|
279
|
+
parts.push(`t:${st.sessionId}:${size}`);
|
|
280
|
+
} catch {
|
|
281
|
+
// stat 失敗は無視(次フレームで回復)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
} catch {
|
|
285
|
+
// readAllSessionStates 自体の失敗も 1 フレームで回復
|
|
286
|
+
}
|
|
287
|
+
return parts.join('|');
|
|
288
|
+
}
|
|
150
289
|
|
|
290
|
+
/**
|
|
291
|
+
* 前回と比べてキーが変化していれば true。副作用として lastRenderKey を更新する。
|
|
292
|
+
*/
|
|
151
293
|
function needsRerender() {
|
|
152
|
-
const
|
|
153
|
-
if (
|
|
154
|
-
|
|
294
|
+
const key = computeRenderKey();
|
|
295
|
+
if (key !== lastRenderKey) {
|
|
296
|
+
lastRenderKey = key;
|
|
155
297
|
return true;
|
|
156
298
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
// mtime は同じでも transcript JSONL のサイズが変われば再描画したい
|
|
164
|
-
// → transcript-usage のキャッシュ判定に任せるため毎秒呼ぶ設計。
|
|
165
|
-
// state ファイル変化なしでも再計算は走らせる(キャッシュヒット時は軽量)
|
|
166
|
-
return true;
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** テスト用リセット */
|
|
303
|
+
function resetRenderKeyCache() {
|
|
304
|
+
lastRenderKey = '';
|
|
167
305
|
}
|
|
168
306
|
|
|
169
307
|
function renderFrame(args) {
|
|
@@ -174,9 +312,15 @@ function renderFrame(args) {
|
|
|
174
312
|
|
|
175
313
|
const lines = [];
|
|
176
314
|
if (filtered.length === 0) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
lines.push(color(ANSI.
|
|
315
|
+
if (args.session) {
|
|
316
|
+
// --session 指定で未ヒットは「存在しない」と明示(一般の "待機中" と区別)
|
|
317
|
+
lines.push(color(ANSI.yellow, `[Throughline] セッション "${args.session}" に一致する active セッションが見つかりません`));
|
|
318
|
+
lines.push(color(ANSI.dim, ' (プレフィックス指定も可。--all で stale を含めて再検索)'));
|
|
319
|
+
} else {
|
|
320
|
+
lines.push(color(ANSI.dim, '[Throughline] 待機中 — アクティブなセッションがありません'));
|
|
321
|
+
if (!args.all) {
|
|
322
|
+
lines.push(color(ANSI.dim, ` (${normalizeProjectPath(process.cwd())} に state 無し。--all で全プロジェクト表示)`));
|
|
323
|
+
}
|
|
180
324
|
}
|
|
181
325
|
} else {
|
|
182
326
|
const header = color(
|
|
@@ -191,13 +335,14 @@ function renderFrame(args) {
|
|
|
191
335
|
}
|
|
192
336
|
}
|
|
193
337
|
|
|
194
|
-
// ★ 折り返し対策: 各行を (columns - 1)
|
|
338
|
+
// ★ 折り返し対策: 各行を (columns - 1) セル幅に切り詰めて物理 1 行に収める。
|
|
195
339
|
// こうすれば ANSI.up(lines.length) と論理行数が物理行数と一致する。
|
|
196
340
|
// columns - 1 にしてるのはターミナル末尾列に書くと自動改行する端末があるため。
|
|
341
|
+
// truncateToCells は CJK / 絵文字を 2 セルとして正しく数える。
|
|
197
342
|
const columns = process.stdout.columns && process.stdout.columns > 10
|
|
198
343
|
? process.stdout.columns - 1
|
|
199
344
|
: 120;
|
|
200
|
-
const clipped = lines.map((l) =>
|
|
345
|
+
const clipped = lines.map((l) => truncateToCells(l, columns));
|
|
201
346
|
|
|
202
347
|
// 前フレームを消去してから再描画:
|
|
203
348
|
// 1. カーソルを前フレームの先頭行へ戻す (CUU = 行移動のみ)
|
|
@@ -213,25 +358,98 @@ function renderFrame(args) {
|
|
|
213
358
|
}
|
|
214
359
|
|
|
215
360
|
// --- 起動 ---
|
|
216
|
-
|
|
217
|
-
|
|
361
|
+
let cursorRestored = false;
|
|
362
|
+
function restoreCursor() {
|
|
363
|
+
if (cursorRestored) return;
|
|
364
|
+
cursorRestored = true;
|
|
365
|
+
try {
|
|
366
|
+
process.stdout.write(ANSI.showCursor);
|
|
367
|
+
} catch {
|
|
368
|
+
// stdout がすでに閉じていても無視
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function safeRenderFrame(args) {
|
|
373
|
+
try {
|
|
374
|
+
renderFrame(args);
|
|
375
|
+
} catch (err) {
|
|
376
|
+
const msg = err instanceof Error ? err.message : 'unknown error';
|
|
377
|
+
process.stderr.write(`[Throughline] render error: ${msg}\n`);
|
|
378
|
+
// 1 フレームの失敗で常駐を落とさない。次フレームで回復を試す
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function main() {
|
|
383
|
+
let args;
|
|
384
|
+
try {
|
|
385
|
+
args = parseArgs(process.argv.slice(2));
|
|
386
|
+
} catch (err) {
|
|
387
|
+
const msg = err instanceof Error ? err.message : 'invalid args';
|
|
388
|
+
process.stderr.write(`[Throughline] ${msg}\n`);
|
|
389
|
+
process.exit(2);
|
|
390
|
+
}
|
|
218
391
|
|
|
219
392
|
process.stdout.write(ANSI.hideCursor);
|
|
220
393
|
process.stdout.write(color(ANSI.dim, `[Throughline] モニター起動 (state: ${getStateDir()}, Ctrl+C で終了)\n`));
|
|
221
394
|
|
|
222
|
-
|
|
395
|
+
safeRenderFrame(args);
|
|
223
396
|
const timer = setInterval(() => {
|
|
224
|
-
if (needsRerender())
|
|
397
|
+
if (needsRerender()) safeRenderFrame(args);
|
|
225
398
|
}, REFRESH_MS);
|
|
226
399
|
|
|
227
|
-
|
|
400
|
+
// ターミナル幅が変わったら即座に全画面リフレッシュ(前フレームの ANSI 座標が無効化されるため)
|
|
401
|
+
// debounce 200ms でドラッグ中のジッタを吸収
|
|
402
|
+
let resizeTimer = null;
|
|
403
|
+
const onResize = () => {
|
|
404
|
+
if (resizeTimer) clearTimeout(resizeTimer);
|
|
405
|
+
resizeTimer = setTimeout(() => {
|
|
406
|
+
resizeTimer = null;
|
|
407
|
+
// 既存描画が新しい幅では崩れている可能性があるため座標情報を破棄して再描画
|
|
408
|
+
lastRenderedLines = 0;
|
|
409
|
+
resetRenderKeyCache();
|
|
410
|
+
safeRenderFrame(args);
|
|
411
|
+
}, 200);
|
|
412
|
+
};
|
|
413
|
+
if (typeof process.stdout.on === 'function') {
|
|
414
|
+
process.stdout.on('resize', onResize);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const shutdown = (code = 0) => {
|
|
228
418
|
clearInterval(timer);
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
process.
|
|
419
|
+
if (resizeTimer) clearTimeout(resizeTimer);
|
|
420
|
+
restoreCursor();
|
|
421
|
+
process.stdout.write('\n' + color(ANSI.dim, '[Throughline] モニター終了\n'));
|
|
422
|
+
process.exit(code);
|
|
232
423
|
};
|
|
233
|
-
process.on('SIGINT', shutdown);
|
|
234
|
-
process.on('SIGTERM', shutdown);
|
|
424
|
+
process.on('SIGINT', () => shutdown(0));
|
|
425
|
+
process.on('SIGTERM', () => shutdown(0));
|
|
426
|
+
|
|
427
|
+
// クラッシュ時もカーソルを必ず戻す
|
|
428
|
+
process.on('exit', restoreCursor);
|
|
429
|
+
process.on('uncaughtException', (err) => {
|
|
430
|
+
restoreCursor();
|
|
431
|
+
const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
432
|
+
process.stderr.write(`[Throughline] uncaught exception:\n${msg}\n`);
|
|
433
|
+
process.exit(1);
|
|
434
|
+
});
|
|
435
|
+
process.on('unhandledRejection', (reason) => {
|
|
436
|
+
restoreCursor();
|
|
437
|
+
process.stderr.write(`[Throughline] unhandled rejection: ${String(reason)}\n`);
|
|
438
|
+
process.exit(1);
|
|
439
|
+
});
|
|
235
440
|
}
|
|
236
441
|
|
|
237
|
-
|
|
442
|
+
// --- テスト用エクスポート(本番コードからは参照しない) ---
|
|
443
|
+
export const _internal = {
|
|
444
|
+
parseArgs,
|
|
445
|
+
filterStates,
|
|
446
|
+
cellWidth,
|
|
447
|
+
truncateToCells,
|
|
448
|
+
padCellsEnd,
|
|
449
|
+
formatNumber,
|
|
450
|
+
renderBar,
|
|
451
|
+
formatLine,
|
|
452
|
+
computeRenderKey,
|
|
453
|
+
needsRerender,
|
|
454
|
+
resetRenderKeyCache,
|
|
455
|
+
};
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { _internal } from './token-monitor.mjs';
|
|
5
|
+
import { normalizeProjectPath } from './state-file.mjs';
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
parseArgs,
|
|
9
|
+
filterStates,
|
|
10
|
+
cellWidth,
|
|
11
|
+
truncateToCells,
|
|
12
|
+
padCellsEnd,
|
|
13
|
+
formatNumber,
|
|
14
|
+
renderBar,
|
|
15
|
+
formatLine,
|
|
16
|
+
} = _internal;
|
|
17
|
+
|
|
18
|
+
// state-file は projectPath を resolve + lowercase 正規化する。
|
|
19
|
+
// filterStates は cwd 引数を内部で正規化するので、テストでも同じ関数を使って揃える。
|
|
20
|
+
const CWD_FOO = normalizeProjectPath('/tmp/foo');
|
|
21
|
+
const CWD_BAR = normalizeProjectPath('/tmp/bar');
|
|
22
|
+
|
|
23
|
+
// ─── parseArgs ─────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
test('parseArgs: 引数なしは defaults', () => {
|
|
26
|
+
assert.deepEqual(parseArgs([]), { all: false, session: null });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('parseArgs: --all フラグ', () => {
|
|
30
|
+
assert.deepEqual(parseArgs(['--all']), { all: true, session: null });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('parseArgs: --session <id>', () => {
|
|
34
|
+
assert.deepEqual(parseArgs(['--session', 'abc123']), { all: false, session: 'abc123' });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('parseArgs: --all と --session の組み合わせ', () => {
|
|
38
|
+
assert.deepEqual(parseArgs(['--all', '--session', 'abc']), { all: true, session: 'abc' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('parseArgs: --session 値欠落は throw する', () => {
|
|
42
|
+
assert.throws(() => parseArgs(['--session']), /requires a session id/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('parseArgs: --session の次が別フラグなら throw する', () => {
|
|
46
|
+
assert.throws(() => parseArgs(['--session', '--all']), /requires a session id/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('parseArgs: 未知の引数は黙殺', () => {
|
|
50
|
+
// 将来 --help などを足す余地を残すため、現状は黙殺で OK
|
|
51
|
+
assert.deepEqual(parseArgs(['--unknown', 'value']), { all: false, session: null });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ─── filterStates ─────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
function makeState({ sessionId, projectPath, stale = false }) {
|
|
57
|
+
return {
|
|
58
|
+
sessionId,
|
|
59
|
+
projectPath,
|
|
60
|
+
transcriptPath: null,
|
|
61
|
+
updatedAt: Date.now(),
|
|
62
|
+
stale,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test('filterStates: --all なしでは stale を隠す', () => {
|
|
67
|
+
const states = [
|
|
68
|
+
makeState({ sessionId: 'a', projectPath: CWD_FOO, stale: false }),
|
|
69
|
+
makeState({ sessionId: 'b', projectPath: CWD_FOO, stale: true }),
|
|
70
|
+
];
|
|
71
|
+
const result = filterStates(states, { all: false, session: null }, CWD_FOO);
|
|
72
|
+
assert.equal(result.length, 1);
|
|
73
|
+
assert.equal(result[0].sessionId, 'a');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('filterStates: --all は stale も含む', () => {
|
|
77
|
+
const states = [
|
|
78
|
+
makeState({ sessionId: 'a', projectPath: CWD_FOO, stale: false }),
|
|
79
|
+
makeState({ sessionId: 'b', projectPath: CWD_BAR, stale: true }),
|
|
80
|
+
];
|
|
81
|
+
const result = filterStates(states, { all: true, session: null }, CWD_FOO);
|
|
82
|
+
assert.equal(result.length, 2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('filterStates: --session は base (stale フィルタ済み) 上でプレフィックス一致', () => {
|
|
86
|
+
const states = [
|
|
87
|
+
makeState({ sessionId: 'abc123', projectPath: CWD_FOO, stale: false }),
|
|
88
|
+
makeState({ sessionId: 'abc999', projectPath: CWD_FOO, stale: true }), // stale は除外される
|
|
89
|
+
makeState({ sessionId: 'def456', projectPath: CWD_FOO, stale: false }),
|
|
90
|
+
];
|
|
91
|
+
const result = filterStates(states, { all: false, session: 'abc' }, CWD_FOO);
|
|
92
|
+
assert.equal(result.length, 1);
|
|
93
|
+
assert.equal(result[0].sessionId, 'abc123');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('filterStates: --session + --all なら stale 含めたうえでプレフィックス一致', () => {
|
|
97
|
+
const states = [
|
|
98
|
+
makeState({ sessionId: 'abc123', projectPath: CWD_FOO, stale: false }),
|
|
99
|
+
makeState({ sessionId: 'abc999', projectPath: CWD_FOO, stale: true }),
|
|
100
|
+
];
|
|
101
|
+
const result = filterStates(states, { all: true, session: 'abc' }, CWD_FOO);
|
|
102
|
+
assert.equal(result.length, 2);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('filterStates: --session 完全一致もプレフィックス一致の一部として拾う', () => {
|
|
106
|
+
const states = [
|
|
107
|
+
makeState({ sessionId: 'exact-match-id', projectPath: CWD_FOO, stale: false }),
|
|
108
|
+
];
|
|
109
|
+
const result = filterStates(states, { all: false, session: 'exact-match-id' }, CWD_FOO);
|
|
110
|
+
assert.equal(result.length, 1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('filterStates: cwd 不一致は除外(--session も --all もなし)', () => {
|
|
114
|
+
const states = [
|
|
115
|
+
makeState({ sessionId: 'a', projectPath: CWD_FOO, stale: false }),
|
|
116
|
+
makeState({ sessionId: 'b', projectPath: CWD_BAR, stale: false }),
|
|
117
|
+
];
|
|
118
|
+
const result = filterStates(states, { all: false, session: null }, CWD_FOO);
|
|
119
|
+
assert.equal(result.length, 1);
|
|
120
|
+
assert.equal(result[0].sessionId, 'a');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ─── cellWidth ─────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
test('cellWidth: ASCII は 1 セル', () => {
|
|
126
|
+
assert.equal(cellWidth('abc'), 3);
|
|
127
|
+
assert.equal(cellWidth(''), 0);
|
|
128
|
+
assert.equal(cellWidth('Hello World'), 11);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('cellWidth: CJK は 2 セル', () => {
|
|
132
|
+
assert.equal(cellWidth('あ'), 2);
|
|
133
|
+
assert.equal(cellWidth('漢字'), 4);
|
|
134
|
+
assert.equal(cellWidth('한글'), 4);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('cellWidth: 絵文字は 2 セル', () => {
|
|
138
|
+
assert.equal(cellWidth('😀'), 2);
|
|
139
|
+
assert.equal(cellWidth('🚀🎉'), 4);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('cellWidth: ANSI エスケープは 0 セル', () => {
|
|
143
|
+
assert.equal(cellWidth('\x1b[32mhello\x1b[0m'), 5);
|
|
144
|
+
assert.equal(cellWidth('\x1b[1;36m★\x1b[0m'), 1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('cellWidth: 混在 (ASCII + CJK + 絵文字 + ANSI)', () => {
|
|
148
|
+
const line = '\x1b[32m▶\x1b[0m Throughline プロジェクト';
|
|
149
|
+
// ▶ (U+25B6) は "Geometric Shapes" で現状 1 セル扱い、ASCII 12 + ひらがな 4 * 2
|
|
150
|
+
// "Throughline " = 12, "プロジェクト" = 12 (6 文字 * 2), ▶ = 1, 空白 = 1
|
|
151
|
+
assert.equal(cellWidth(line), 1 + 1 + 12 + 12);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('cellWidth: 制御文字は 0', () => {
|
|
155
|
+
assert.equal(cellWidth('\x00\x01\x02'), 0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('cellWidth: ZWJ は 0', () => {
|
|
159
|
+
assert.equal(cellWidth('a\u200db'), 2); // a + ZWJ (0) + b
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ─── truncateToCells ──────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
test('truncateToCells: ASCII で単純切り詰め', () => {
|
|
165
|
+
const result = truncateToCells('abcdefghij', 5);
|
|
166
|
+
// 切り詰め後に reset が付く
|
|
167
|
+
assert.ok(result.startsWith('abcde'));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('truncateToCells: CJK 境界で 1 セル余る場合は空白で埋める', () => {
|
|
171
|
+
// maxCells=5 で "あいう" (6 セル) を切ると "あい" (4 セル) + 1 セル分空白
|
|
172
|
+
const result = truncateToCells('あいうえお', 5);
|
|
173
|
+
const stripped = result.replace(/\x1b\[[0-9;]*m/g, '');
|
|
174
|
+
// CJK 2 セル × 2 = 4 セル + 空白 1 = 5 セル
|
|
175
|
+
assert.ok(stripped.startsWith('あい '));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('truncateToCells: 既に収まっていればそのまま', () => {
|
|
179
|
+
assert.equal(truncateToCells('abc', 10), 'abc');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('truncateToCells: ANSI コードを破壊しない', () => {
|
|
183
|
+
const input = '\x1b[32mhello\x1b[0m world';
|
|
184
|
+
const result = truncateToCells(input, 7);
|
|
185
|
+
// ANSI がそのまま残り、可視部分は "hello w" で切れる
|
|
186
|
+
assert.ok(result.includes('\x1b[32m'));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('truncateToCells: maxCells=0 は空文字列', () => {
|
|
190
|
+
assert.equal(truncateToCells('hello', 0), '');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ─── padCellsEnd ──────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
test('padCellsEnd: ASCII を右端パディング', () => {
|
|
196
|
+
assert.equal(padCellsEnd('abc', 6), 'abc ');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('padCellsEnd: CJK でも正しく幅を計算してパディング', () => {
|
|
200
|
+
// "あい" = 4 セル、target 6 → 空白 2 個付加
|
|
201
|
+
assert.equal(padCellsEnd('あい', 6), 'あい ');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('padCellsEnd: ちょうど target なら変化なし', () => {
|
|
205
|
+
assert.equal(padCellsEnd('abc', 3), 'abc');
|
|
206
|
+
assert.equal(padCellsEnd('漢字', 4), '漢字');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('padCellsEnd: target 超過なら切り詰め', () => {
|
|
210
|
+
const result = padCellsEnd('abcdefgh', 4);
|
|
211
|
+
const stripped = result.replace(/\x1b\[[0-9;]*m/g, '');
|
|
212
|
+
assert.equal(stripped.slice(0, 4), 'abcd');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ─── formatNumber ─────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
test('formatNumber: 1000 未満はそのまま(小数なし)', () => {
|
|
218
|
+
assert.equal(formatNumber(0), '0');
|
|
219
|
+
assert.equal(formatNumber(42), '42');
|
|
220
|
+
assert.equal(formatNumber(999), '999');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('formatNumber: 1_000 以上は k 表記', () => {
|
|
224
|
+
assert.equal(formatNumber(1_000), '1.0k');
|
|
225
|
+
assert.equal(formatNumber(1_234), '1.2k');
|
|
226
|
+
assert.equal(formatNumber(999_499), '999.5k');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('formatNumber: 999_500 以上は M 表記にジャンプ("1000.0k" 回避)', () => {
|
|
230
|
+
assert.equal(formatNumber(999_500), '1.00M');
|
|
231
|
+
assert.equal(formatNumber(999_950), '1.00M');
|
|
232
|
+
assert.equal(formatNumber(999_999), '1.00M');
|
|
233
|
+
assert.equal(formatNumber(1_000_000), '1.00M');
|
|
234
|
+
assert.equal(formatNumber(1_234_567), '1.23M');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('formatNumber: 無効値は "0"', () => {
|
|
238
|
+
assert.equal(formatNumber(NaN), '0');
|
|
239
|
+
assert.equal(formatNumber(-1), '0');
|
|
240
|
+
assert.equal(formatNumber(Infinity), '0');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ─── renderBar ────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
test('renderBar: ratio=0 は全部 ░', () => {
|
|
246
|
+
assert.equal(renderBar(0, 5), '░░░░░');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('renderBar: ratio=1 は全部 █', () => {
|
|
250
|
+
assert.equal(renderBar(1, 5), '█████');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('renderBar: ratio=0.5 は半々', () => {
|
|
254
|
+
// width=10, 0.5 * 10 = 5 filled
|
|
255
|
+
assert.equal(renderBar(0.5, 10), '█████░░░░░');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('renderBar: NaN でもバーが消えない', () => {
|
|
259
|
+
const result = renderBar(NaN, 5);
|
|
260
|
+
assert.equal(result.length, 5);
|
|
261
|
+
assert.ok(result.includes('░'));
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('renderBar: Infinity / 負値は安全にクランプ', () => {
|
|
265
|
+
assert.equal(renderBar(Infinity, 5), '█████');
|
|
266
|
+
assert.equal(renderBar(-1, 5), '░░░░░');
|
|
267
|
+
assert.equal(renderBar(1.5, 5), '█████');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ─── formatLine 警告表示(色覚配慮) ────────────────────────────
|
|
271
|
+
|
|
272
|
+
function stripColors(s) {
|
|
273
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function makeLineArgs(ratio) {
|
|
277
|
+
const max = 200_000;
|
|
278
|
+
const tokens = Math.round(ratio * max);
|
|
279
|
+
return {
|
|
280
|
+
state: {
|
|
281
|
+
sessionId: 'abc12345-xxxx',
|
|
282
|
+
projectPath: '/tmp/foo',
|
|
283
|
+
transcriptPath: null,
|
|
284
|
+
updatedAt: Date.now(),
|
|
285
|
+
},
|
|
286
|
+
usage: {
|
|
287
|
+
tokens,
|
|
288
|
+
model: 'test-model',
|
|
289
|
+
contextWindowSize: max,
|
|
290
|
+
outputTokens: 0,
|
|
291
|
+
},
|
|
292
|
+
isActive: true,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
test('formatLine: 70% 未満は警告テキストなし', () => {
|
|
297
|
+
const out = stripColors(formatLine(makeLineArgs(0.5)));
|
|
298
|
+
assert.ok(!out.includes('!!'));
|
|
299
|
+
assert.ok(!out.includes('! '));
|
|
300
|
+
assert.ok(!out.includes('/clear'));
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('formatLine: 70% 以上で "!" マーカーと弱めの文言', () => {
|
|
304
|
+
const out = stripColors(formatLine(makeLineArgs(0.75)));
|
|
305
|
+
assert.ok(out.includes('!'), 'should include ! marker');
|
|
306
|
+
assert.ok(out.includes('そろそろ /clear'), 'should show soft warning');
|
|
307
|
+
assert.ok(!out.includes('!!'), 'should not include critical marker yet');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('formatLine: 90% 以上で "!!" マーカーと強い文言', () => {
|
|
311
|
+
const out = stripColors(formatLine(makeLineArgs(0.95)));
|
|
312
|
+
assert.ok(out.includes('!!'), 'should include !! critical marker');
|
|
313
|
+
assert.ok(out.includes('強く推奨'), 'should show strong warning');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('formatLine: 色付きで警告が赤 / 黄になる(色覚配慮の裏付け)', () => {
|
|
317
|
+
const critical = formatLine(makeLineArgs(0.95));
|
|
318
|
+
assert.ok(critical.includes('\x1b[31m'), 'critical should use red');
|
|
319
|
+
const warning = formatLine(makeLineArgs(0.75));
|
|
320
|
+
assert.ok(warning.includes('\x1b[33m'), 'warning should use yellow');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('formatLine: プロジェクト名に CJK が含まれてもセル幅で整形される', () => {
|
|
324
|
+
const args = makeLineArgs(0.5);
|
|
325
|
+
args.state.projectPath = '/tmp/プロジェクト名';
|
|
326
|
+
const out = formatLine(args);
|
|
327
|
+
// basename で "プロジェクト名" (7 文字, 14 セル) が project 欄に入る。
|
|
328
|
+
// padCellsEnd(..., 18) で 14 セル + 4 セル空白になる。セル幅を数える
|
|
329
|
+
// のは難しいがクラッシュしないことと想定文字列が含まれることを最低限確認
|
|
330
|
+
assert.ok(stripColors(out).includes('プロジェクト名'));
|
|
331
|
+
});
|
package/src/transcript-usage.mjs
CHANGED
|
@@ -72,20 +72,26 @@ function hasContextWindowHint(raw) {
|
|
|
72
72
|
return /\[1m\]|1M context/i.test(raw);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/** @type {Map<string, {size: number, sample: UsageSample|null}>} */
|
|
75
|
+
/** @type {Map<string, {size: number, mtimeMs: number, sample: UsageSample|null, stickyWindow: number}>} */
|
|
76
76
|
const cache = new Map();
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
|
-
* transcript JSONL から最新の assistant usage
|
|
79
|
+
* transcript JSONL から最新の assistant usage を抽出する。
|
|
80
|
+
*
|
|
81
|
+
* キャッシュ無効化は size + mtimeMs の両方を比較する(同サイズで内容差し替えも検出)。
|
|
82
|
+
* sticky 1M: 一度でも 1M 文脈窓と判定した path は以後 1M のまま(200k→1M 境界での
|
|
83
|
+
* バー急変を防ぐ)。プロセス再起動でリセット、monitor 再起動時でも初回サンプルで
|
|
84
|
+
* 即復帰するので実害なし。
|
|
85
|
+
*
|
|
80
86
|
* @param {string} transcriptPath
|
|
81
87
|
* @returns {UsageSample | null}
|
|
82
88
|
*/
|
|
83
89
|
export function readLatestUsage(transcriptPath) {
|
|
84
90
|
if (!transcriptPath || !existsSync(transcriptPath)) return null;
|
|
85
91
|
|
|
86
|
-
const
|
|
92
|
+
const stat = statSync(transcriptPath);
|
|
87
93
|
const cached = cache.get(transcriptPath);
|
|
88
|
-
if (cached && cached.size === size) {
|
|
94
|
+
if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
|
|
89
95
|
return cached.sample;
|
|
90
96
|
}
|
|
91
97
|
|
|
@@ -110,15 +116,30 @@ export function readLatestUsage(transcriptPath) {
|
|
|
110
116
|
(usage.cache_creation_input_tokens ?? 0) +
|
|
111
117
|
(usage.cache_read_input_tokens ?? 0);
|
|
112
118
|
const model = entry.message?.model ?? '';
|
|
119
|
+
const detected = inferContextWindowSize(model, tokens, rawHint);
|
|
113
120
|
latest = {
|
|
114
121
|
tokens,
|
|
115
122
|
model,
|
|
116
|
-
contextWindowSize:
|
|
123
|
+
contextWindowSize: detected,
|
|
117
124
|
outputTokens: usage.output_tokens ?? 0,
|
|
118
125
|
};
|
|
119
126
|
}
|
|
120
127
|
|
|
121
|
-
|
|
128
|
+
// sticky 1M: 既に 1M を観測していたらその値を維持
|
|
129
|
+
const priorSticky = cached?.stickyWindow ?? 0;
|
|
130
|
+
const stickyWindow = latest
|
|
131
|
+
? Math.max(latest.contextWindowSize, priorSticky)
|
|
132
|
+
: priorSticky;
|
|
133
|
+
if (latest && stickyWindow > latest.contextWindowSize) {
|
|
134
|
+
latest.contextWindowSize = stickyWindow;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
cache.set(transcriptPath, {
|
|
138
|
+
size: stat.size,
|
|
139
|
+
mtimeMs: stat.mtimeMs,
|
|
140
|
+
sample: latest,
|
|
141
|
+
stickyWindow,
|
|
142
|
+
});
|
|
122
143
|
return latest;
|
|
123
144
|
}
|
|
124
145
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, writeFileSync, rmSync, utimesSync, statSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
|
|
7
|
+
import { readLatestUsage, clearUsageCache, inferContextWindowSize } from './transcript-usage.mjs';
|
|
8
|
+
|
|
9
|
+
function writeTranscript(path, entries) {
|
|
10
|
+
writeFileSync(path, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function assistantEntry({ model, inputTokens, cacheCreate = 0, cacheRead = 0, outputTokens = 0 }) {
|
|
14
|
+
return {
|
|
15
|
+
type: 'assistant',
|
|
16
|
+
message: {
|
|
17
|
+
model,
|
|
18
|
+
usage: {
|
|
19
|
+
input_tokens: inputTokens,
|
|
20
|
+
cache_creation_input_tokens: cacheCreate,
|
|
21
|
+
cache_read_input_tokens: cacheRead,
|
|
22
|
+
output_tokens: outputTokens,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function withFixture(fn) {
|
|
29
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-usage-'));
|
|
30
|
+
const path = join(dir, 'transcript.jsonl');
|
|
31
|
+
clearUsageCache();
|
|
32
|
+
try {
|
|
33
|
+
fn({ dir, path });
|
|
34
|
+
} finally {
|
|
35
|
+
clearUsageCache();
|
|
36
|
+
rmSync(dir, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── inferContextWindowSize ────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
test('inferContextWindowSize: [1m] サフィックスは 1M', () => {
|
|
43
|
+
assert.equal(inferContextWindowSize('claude-opus-4-6[1m]', 0, false), 1_000_000);
|
|
44
|
+
assert.equal(inferContextWindowSize('claude-sonnet-4-7[1M]', 0, false), 1_000_000);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('inferContextWindowSize: rawHint=true なら 1M', () => {
|
|
48
|
+
assert.equal(inferContextWindowSize('claude-opus-4-6', 0, true), 1_000_000);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('inferContextWindowSize: 200k 超観測でも 1M', () => {
|
|
52
|
+
assert.equal(inferContextWindowSize('claude-opus-4-6', 250_000, false), 1_000_000);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('inferContextWindowSize: デフォルトは 200k', () => {
|
|
56
|
+
assert.equal(inferContextWindowSize('claude-opus-4-6', 100_000, false), 200_000);
|
|
57
|
+
assert.equal(inferContextWindowSize('', 0, false), 200_000);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ─── readLatestUsage ──────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
test('readLatestUsage: 存在しないファイルは null', () => {
|
|
63
|
+
assert.equal(readLatestUsage('/nonexistent/path'), null);
|
|
64
|
+
assert.equal(readLatestUsage(''), null);
|
|
65
|
+
assert.equal(readLatestUsage(null), null);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('readLatestUsage: 最新の assistant エントリを返す', () => {
|
|
69
|
+
withFixture(({ path }) => {
|
|
70
|
+
writeTranscript(path, [
|
|
71
|
+
assistantEntry({ model: 'claude-opus-4-6', inputTokens: 1000 }),
|
|
72
|
+
{ type: 'user', message: { content: 'hi' } },
|
|
73
|
+
assistantEntry({ model: 'claude-opus-4-6', inputTokens: 5000, cacheRead: 1000, outputTokens: 100 }),
|
|
74
|
+
]);
|
|
75
|
+
const result = readLatestUsage(path);
|
|
76
|
+
assert.ok(result);
|
|
77
|
+
assert.equal(result.tokens, 6000);
|
|
78
|
+
assert.equal(result.model, 'claude-opus-4-6');
|
|
79
|
+
assert.equal(result.outputTokens, 100);
|
|
80
|
+
assert.equal(result.contextWindowSize, 200_000);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('readLatestUsage: usage なしエントリは skip', () => {
|
|
85
|
+
withFixture(({ path }) => {
|
|
86
|
+
writeTranscript(path, [
|
|
87
|
+
{ type: 'assistant', message: { model: 'x', content: [] } },
|
|
88
|
+
assistantEntry({ model: 'claude-opus-4-6', inputTokens: 500 }),
|
|
89
|
+
]);
|
|
90
|
+
const result = readLatestUsage(path);
|
|
91
|
+
assert.equal(result.tokens, 500);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('readLatestUsage: キャッシュが mtime 変化で無効化される', () => {
|
|
96
|
+
withFixture(({ path }) => {
|
|
97
|
+
writeTranscript(path, [assistantEntry({ model: 'x', inputTokens: 100 })]);
|
|
98
|
+
const first = readLatestUsage(path);
|
|
99
|
+
assert.equal(first.tokens, 100);
|
|
100
|
+
|
|
101
|
+
// 同じサイズで中身を差し替え、mtime も進める
|
|
102
|
+
writeTranscript(path, [assistantEntry({ model: 'x', inputTokens: 999 })]);
|
|
103
|
+
// 強制的に mtime を 2 秒後にする(OS によっては書き込み直後でも mtime が同じことがある)
|
|
104
|
+
const future = new Date(Date.now() + 2000);
|
|
105
|
+
utimesSync(path, future, future);
|
|
106
|
+
|
|
107
|
+
const second = readLatestUsage(path);
|
|
108
|
+
assert.equal(second.tokens, 999, 'cache should be invalidated by mtime change');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ─── sticky 1M ──────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
test('readLatestUsage: 一度 1M を観測したら以後は下がらない', () => {
|
|
115
|
+
withFixture(({ path }) => {
|
|
116
|
+
// 初回: 250k 観測 → 1M 判定
|
|
117
|
+
writeTranscript(path, [assistantEntry({ model: 'claude-opus-4-6', inputTokens: 250_000 })]);
|
|
118
|
+
const first = readLatestUsage(path);
|
|
119
|
+
assert.equal(first.contextWindowSize, 1_000_000);
|
|
120
|
+
|
|
121
|
+
// 次: 100k に下がっても window は 1M のまま維持(sticky)
|
|
122
|
+
writeTranscript(path, [assistantEntry({ model: 'claude-opus-4-6', inputTokens: 100_000 })]);
|
|
123
|
+
const future = new Date(Date.now() + 2000);
|
|
124
|
+
utimesSync(path, future, future);
|
|
125
|
+
|
|
126
|
+
const second = readLatestUsage(path);
|
|
127
|
+
assert.equal(second.tokens, 100_000);
|
|
128
|
+
assert.equal(second.contextWindowSize, 1_000_000, 'sticky 1M must remain after observation');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('readLatestUsage: clearUsageCache で sticky もリセット', () => {
|
|
133
|
+
withFixture(({ path }) => {
|
|
134
|
+
writeTranscript(path, [assistantEntry({ model: 'claude-opus-4-6', inputTokens: 250_000 })]);
|
|
135
|
+
readLatestUsage(path);
|
|
136
|
+
clearUsageCache();
|
|
137
|
+
|
|
138
|
+
writeTranscript(path, [assistantEntry({ model: 'claude-opus-4-6', inputTokens: 100_000 })]);
|
|
139
|
+
const future = new Date(Date.now() + 2000);
|
|
140
|
+
utimesSync(path, future, future);
|
|
141
|
+
|
|
142
|
+
const result = readLatestUsage(path);
|
|
143
|
+
assert.equal(result.contextWindowSize, 200_000, 'sticky should reset after clearUsageCache');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('readLatestUsage: partial-write JSONL 行は skip', () => {
|
|
148
|
+
withFixture(({ path }) => {
|
|
149
|
+
// 最後の行が壊れている
|
|
150
|
+
writeFileSync(
|
|
151
|
+
path,
|
|
152
|
+
JSON.stringify(assistantEntry({ model: 'x', inputTokens: 42 })) +
|
|
153
|
+
'\n' +
|
|
154
|
+
'{"type":"assistant","message":{"model":"x","us',
|
|
155
|
+
);
|
|
156
|
+
const result = readLatestUsage(path);
|
|
157
|
+
assert.equal(result.tokens, 42);
|
|
158
|
+
});
|
|
159
|
+
});
|