@cocorograph/hub-agent 0.7.75 → 0.7.77
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/package.json +1 -1
- package/src/claude-history.mjs +205 -3
- package/src/codex-stream-bridge.mjs +32 -8
package/package.json
CHANGED
package/src/claude-history.mjs
CHANGED
|
@@ -73,6 +73,68 @@ export function clearHydrateCacheForTest() {
|
|
|
73
73
|
hydrateCache.clear()
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* listSessions の並び順キー last_turn_at_ms を **cwd(dir) 単位** でキャッシュする。
|
|
78
|
+
*
|
|
79
|
+
* 背景 (finding 3): listSessions は Cockpit の閲覧ハートビート経路 (main.mjs) からも
|
|
80
|
+
* limit:1 で周期的に呼ばれる。毎回 cwd 配下の全 jsonl を stat→open→末尾 read すると、
|
|
81
|
+
* セッション数 N に比例した定期 I/O (最悪 64KB×N) が閲覧者ごとに走る。ほとんどのファイルは
|
|
82
|
+
* 会話が進んでいない (mtime/size 不変) ので、キャッシュヒット時はディスク読みを丸ごと省く。
|
|
83
|
+
*
|
|
84
|
+
* ⚠️ round2 finding: 単一グローバル LRU (上限 N 件) だと、1 dir のファイル数がその上限を
|
|
85
|
+
* 超えたとき「毎回同じ順で全件走査 → 先頭 miss が末尾側の既存エントリを順次追い出す」
|
|
86
|
+
* スキャン汚染で結局ほぼ全 miss になる (実環境に 1 dir 1996 件の履歴あり)。これを避けるため
|
|
87
|
+
* キャッシュを **dir → (filePath→{mtimeMs,size,lastTurnAtMs}) の 2 段**にし、LRU 退避は
|
|
88
|
+
* **dir 単位**でのみ行う。1 dir 内の全ファイルは同時に保持されるので、ファイル総数が
|
|
89
|
+
* どれだけ多くても 1 回の走査でキャッシュが自壊しない。dir 数を上限 (同時に見る cwd 数) で
|
|
90
|
+
* 縛るのでメモリも有界。dir 内は毎回の走査で「今存在するファイル」に prune する。
|
|
91
|
+
*/
|
|
92
|
+
const LAST_TURN_DIR_CACHE_MAX = 64 // 同時にキャッシュを保持する cwd(dir) 数の上限
|
|
93
|
+
const lastTurnDirCache = new Map() // dir -> Map<filePath, {mtimeMs, size, lastTurnAtMs}>
|
|
94
|
+
|
|
95
|
+
// テスト用: extractLastTurnAtMs の実 I/O 呼び出し回数 (キャッシュ有効性の回帰テスト用)。
|
|
96
|
+
let extractCallCountForTest = 0
|
|
97
|
+
export function getExtractCallCountForTest() {
|
|
98
|
+
return extractCallCountForTest
|
|
99
|
+
}
|
|
100
|
+
export function resetExtractCallCountForTest() {
|
|
101
|
+
extractCallCountForTest = 0
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getLastTurnDirMap(dir) {
|
|
105
|
+
let fileMap = lastTurnDirCache.get(dir)
|
|
106
|
+
if (fileMap) {
|
|
107
|
+
// LRU touch (再挿入で挿入順を更新)。
|
|
108
|
+
lastTurnDirCache.delete(dir)
|
|
109
|
+
lastTurnDirCache.set(dir, fileMap)
|
|
110
|
+
return fileMap
|
|
111
|
+
}
|
|
112
|
+
fileMap = new Map()
|
|
113
|
+
lastTurnDirCache.set(dir, fileMap)
|
|
114
|
+
while (lastTurnDirCache.size > LAST_TURN_DIR_CACHE_MAX) {
|
|
115
|
+
const oldest = lastTurnDirCache.keys().next().value
|
|
116
|
+
lastTurnDirCache.delete(oldest)
|
|
117
|
+
}
|
|
118
|
+
return fileMap
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function getLastTurnAtMsCached(fileMap, filePath, mtimeMs, size) {
|
|
122
|
+
const entry = fileMap.get(filePath)
|
|
123
|
+
if (entry && entry.mtimeMs === mtimeMs && entry.size === size) return entry.lastTurnAtMs
|
|
124
|
+
// extract に fallback を渡さず null を受ける。巨大最終行 (>4MB) 等で抽出不能のとき、
|
|
125
|
+
// mtime へ飛ばして再浮上させるのではなく **前回の last_turn_at_ms を維持** する (sticky。
|
|
126
|
+
// round2 minor 対応)。前回値も無ければ最後の手段として mtime。
|
|
127
|
+
const extracted = await extractLastTurnAtMs(filePath, null)
|
|
128
|
+
const lastTurnAtMs = extracted ?? entry?.lastTurnAtMs ?? mtimeMs
|
|
129
|
+
fileMap.set(filePath, { mtimeMs, size, lastTurnAtMs })
|
|
130
|
+
return lastTurnAtMs
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** テスト用: last_turn_at_ms キャッシュを全消去する。 */
|
|
134
|
+
export function clearLastTurnCacheForTest() {
|
|
135
|
+
lastTurnDirCache.clear()
|
|
136
|
+
}
|
|
137
|
+
|
|
76
138
|
/**
|
|
77
139
|
* jsonl の生 object を、Browser が SDK message として受け取れる表示用 shape に正規化する。
|
|
78
140
|
* 余分な内部メタは落とし、表示・集計に必要なフィールドだけを superset で拾う。
|
|
@@ -318,6 +380,124 @@ async function extractPreview(filePath) {
|
|
|
318
380
|
return ""
|
|
319
381
|
}
|
|
320
382
|
|
|
383
|
+
/**
|
|
384
|
+
* jsonl ファイルの「最後の会話ターン境界の時刻」(= 末尾メッセージに埋め込まれた timestamp) を
|
|
385
|
+
* ms epoch で返す。無ければ null。
|
|
386
|
+
*
|
|
387
|
+
* 背景 (症状⑨=実害): サイドバーの並び順はファイル mtime 降順だった (listSessions)。だが
|
|
388
|
+
* mtime は「会話への書き込み」以外の操作 (resume 時の jsonl 書き戻し・compact/summary の
|
|
389
|
+
* 全書き換え・ツールによる touch 等) でも bump するため、古いセッションを開いただけで最上位に
|
|
390
|
+
* 浮上する等の並び順ズレを起こす。会話の各行には ISO8601 の `timestamp` が埋め込まれており、
|
|
391
|
+
* これは resume 書き戻しでも歴史値として保存される (= 実際のターン発生時刻)。末尾行の
|
|
392
|
+
* timestamp を採れば「最後にターンが動いた時刻」で安定して並べられる。
|
|
393
|
+
*
|
|
394
|
+
* 末尾だけを読むため、ファイルサイズに関係なく最後の数十 KB だけを fd で読む
|
|
395
|
+
* (Claude jsonl は append-then-close なので末尾行は必ず完結している)。
|
|
396
|
+
*
|
|
397
|
+
* ⚠️ 2 つの落とし穴を回避している:
|
|
398
|
+
* 1. **巨大な最終行** (tool_result / 画像 base64 埋め込み等で 1 メッセージが 64KB 超も普通)。
|
|
399
|
+
* 固定窓だと最終行が窓境界で切れて JSON.parse できず取りこぼす。→ user/assistant の
|
|
400
|
+
* message 行を拾えるまで窓を倍々に読み広げる (上限 MAX_READ)。
|
|
401
|
+
* 2. **非ターン行の timestamp** (message 後の `attachment` / `system` の stop_hook_summary /
|
|
402
|
+
* turn_duration 等にも timestamp があり、会話ターン境界でない操作で時刻が進む)。
|
|
403
|
+
* → 採用対象を `type in {user, assistant}` かつ `message` を持つ行に限定する。
|
|
404
|
+
*
|
|
405
|
+
* @param {string} filePath
|
|
406
|
+
* @param {number} fallbackMtimeMs stat 済みなら渡す (末尾に会話 message が無い時の保険)
|
|
407
|
+
* @returns {Promise<number|null>}
|
|
408
|
+
*/
|
|
409
|
+
const LAST_TURN_TAIL_CHUNK = 65536
|
|
410
|
+
const LAST_TURN_MAX_READ = 4 * 1024 * 1024 // 4MB を超えて遡っても割に合わない → fallback
|
|
411
|
+
|
|
412
|
+
/** テキスト中の「最後の user/assistant message 行」の timestamp(ms) を返す。無ければ null。 */
|
|
413
|
+
function lastTurnTsFromText(text) {
|
|
414
|
+
const lines = text.split("\n")
|
|
415
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
416
|
+
const line = lines[i]
|
|
417
|
+
if (!line) continue
|
|
418
|
+
let obj
|
|
419
|
+
try {
|
|
420
|
+
obj = JSON.parse(line)
|
|
421
|
+
} catch {
|
|
422
|
+
continue
|
|
423
|
+
}
|
|
424
|
+
// 会話ターン境界 = user/assistant の message 行のみ (finding 2)。
|
|
425
|
+
if ((obj?.type === "user" || obj?.type === "assistant") && obj.message) {
|
|
426
|
+
const ts = obj.timestamp
|
|
427
|
+
if (typeof ts === "string") {
|
|
428
|
+
const ms = Date.parse(ts)
|
|
429
|
+
if (!Number.isNaN(ms)) return ms
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return null
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* 巨大行 (読み込み上限 4MB を超え、完全な行として parse できない) で正規 parse が空振り
|
|
438
|
+
* したときの最終手段: tail テキストから最後の `"timestamp":"<ISO>"` を正規表現で拾う
|
|
439
|
+
* (round2 minor / round3 対応)。
|
|
440
|
+
*
|
|
441
|
+
* Claude jsonl の各行はトップレベルに `"timestamp"` を持ち、これは message 本文の**後ろ**に
|
|
442
|
+
* 来る (= 行末側)。よって「最後の一致」を採ればトップレベルの timestamp をほぼ確実に拾える
|
|
443
|
+
* (message 本文内に稀に "timestamp" があっても、それより後ろのトップレベル側が勝つ)。
|
|
444
|
+
* 完全 parse で type を検証できない degraded path 専用。mtime 再浮上 (症状⑨) を避けるための
|
|
445
|
+
* 保守的 fallback として、この不確実性を許容する。
|
|
446
|
+
*/
|
|
447
|
+
const TIMESTAMP_FIELD_RE = /"timestamp":"([^"]+)"/g
|
|
448
|
+
function regexLastTimestampMs(text) {
|
|
449
|
+
let m
|
|
450
|
+
let last = null
|
|
451
|
+
TIMESTAMP_FIELD_RE.lastIndex = 0
|
|
452
|
+
while ((m = TIMESTAMP_FIELD_RE.exec(text)) !== null) last = m[1]
|
|
453
|
+
if (last == null) return null
|
|
454
|
+
const ms = Date.parse(last)
|
|
455
|
+
return Number.isNaN(ms) ? null : ms
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function extractLastTurnAtMs(filePath, fallbackMtimeMs = null) {
|
|
459
|
+
extractCallCountForTest++
|
|
460
|
+
let handle
|
|
461
|
+
try {
|
|
462
|
+
handle = await open(filePath, "r")
|
|
463
|
+
const st = await handle.stat()
|
|
464
|
+
if (st.size <= 0) return fallbackMtimeMs
|
|
465
|
+
let readLen = Math.min(LAST_TURN_TAIL_CHUNK, st.size)
|
|
466
|
+
let hugeLineText = null // 上限まで読んでも完全行を parse できなかった巨大行の raw tail
|
|
467
|
+
for (;;) {
|
|
468
|
+
const start = st.size - readLen
|
|
469
|
+
const buf = Buffer.allocUnsafe(readLen)
|
|
470
|
+
const { bytesRead } = await handle.read(buf, 0, readLen, start)
|
|
471
|
+
const text = buf.toString("utf-8", 0, bytesRead)
|
|
472
|
+
// 窓の先頭が partial の可能性: start>0 なら最初の改行より前 (途中から始まる行) を捨て、
|
|
473
|
+
// 完全な行だけを対象にする。改行が無い (= 1 行が窓より大きい) 場合は下で窓拡張。
|
|
474
|
+
const nl = text.indexOf("\n")
|
|
475
|
+
const usable = start > 0 ? (nl >= 0 ? text.slice(nl + 1) : "") : text
|
|
476
|
+
const ms = lastTurnTsFromText(usable)
|
|
477
|
+
if (ms != null) return ms
|
|
478
|
+
// 見つからず、まだファイル先頭に届いておらず上限内なら窓を倍にして読み足す
|
|
479
|
+
// (巨大な最終行を完全に取り込むまで遡る。finding 1)。
|
|
480
|
+
if (start <= 0 || readLen >= LAST_TURN_MAX_READ) {
|
|
481
|
+
// 上限まで読んでも完全行が取れなかった (start>0 のまま budget 到達) = 巨大最終行。
|
|
482
|
+
// cold path (キャッシュ prev 値も無い) で mtime へ飛んで再浮上しないよう、
|
|
483
|
+
// raw tail から timestamp を正規表現で拾う degraded fallback を試みる。
|
|
484
|
+
if (start > 0 && readLen >= LAST_TURN_MAX_READ) hugeLineText = text
|
|
485
|
+
break
|
|
486
|
+
}
|
|
487
|
+
readLen = Math.min(readLen * 2, st.size, LAST_TURN_MAX_READ)
|
|
488
|
+
}
|
|
489
|
+
if (hugeLineText != null) {
|
|
490
|
+
const ms = regexLastTimestampMs(hugeLineText)
|
|
491
|
+
if (ms != null) return ms
|
|
492
|
+
}
|
|
493
|
+
return fallbackMtimeMs
|
|
494
|
+
} catch {
|
|
495
|
+
return fallbackMtimeMs
|
|
496
|
+
} finally {
|
|
497
|
+
await handle?.close()
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
321
501
|
/**
|
|
322
502
|
* cwd 配下の全 jsonl ファイルから session 一覧を返す。
|
|
323
503
|
* 各ファイルから session_id / 最終更新時刻 / サイズ / 最初の user message preview を抽出。
|
|
@@ -341,6 +521,9 @@ export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
|
|
|
341
521
|
logger?.warn({ err: err.message, dir }, "claude history list failed")
|
|
342
522
|
return { sessions: [], error: err.message }
|
|
343
523
|
}
|
|
524
|
+
// この dir 専用の last_turn_at_ms キャッシュ (dir 単位 LRU。スキャン汚染しない。finding 3/round2)。
|
|
525
|
+
const fileMap = getLastTurnDirMap(dir)
|
|
526
|
+
const seenPaths = new Set()
|
|
344
527
|
const stats = []
|
|
345
528
|
for (const f of files) {
|
|
346
529
|
if (!f.endsWith(".jsonl")) continue
|
|
@@ -348,13 +531,32 @@ export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
|
|
|
348
531
|
const filePath = path.join(dir, f)
|
|
349
532
|
try {
|
|
350
533
|
const st = await stat(filePath)
|
|
351
|
-
|
|
534
|
+
seenPaths.add(filePath)
|
|
535
|
+
// last_turn_at_ms = jsonl 末尾メッセージの埋め込み timestamp (会話ターン境界)。
|
|
536
|
+
// サイドバーの並び順はこれを使う (mtime は resume 書き戻し等でも bump し、古い
|
|
537
|
+
// セッションを開いただけで浮上する症状⑨の原因になるため)。抽出不能なら前回値 sticky。
|
|
538
|
+
// (mtimeMs+size キャッシュ経由。ハートビート経路の周期呼び出しで全ファイルを毎回
|
|
539
|
+
// 読み直さない。finding 3)
|
|
540
|
+
const last_turn_at_ms = await getLastTurnAtMsCached(fileMap, filePath, st.mtimeMs, st.size)
|
|
541
|
+
stats.push({
|
|
542
|
+
session_id,
|
|
543
|
+
file_path: filePath,
|
|
544
|
+
mtime: st.mtimeMs,
|
|
545
|
+
last_turn_at_ms,
|
|
546
|
+
size_bytes: st.size,
|
|
547
|
+
})
|
|
352
548
|
} catch {
|
|
353
549
|
// ignore individual file stat errors
|
|
354
550
|
}
|
|
355
551
|
}
|
|
356
|
-
//
|
|
357
|
-
|
|
552
|
+
// 消えた jsonl (削除・rotate) のキャッシュエントリを掃除し、dir 内キャッシュを有界に保つ。
|
|
553
|
+
if (fileMap.size > seenPaths.size) {
|
|
554
|
+
for (const key of fileMap.keys()) {
|
|
555
|
+
if (!seenPaths.has(key)) fileMap.delete(key)
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
// 会話ターン境界の新しい順 + limit 件に絞ってから preview 抽出 (全ファイル読みを避ける)
|
|
559
|
+
stats.sort((a, b) => b.last_turn_at_ms - a.last_turn_at_ms)
|
|
358
560
|
const top = stats.slice(0, limit)
|
|
359
561
|
const sessions = []
|
|
360
562
|
for (const s of top) {
|
|
@@ -126,7 +126,27 @@ function flattenThreadItems(thread, { maxItems = HISTORY_MAX_ITEMS, maxBytes = H
|
|
|
126
126
|
const turns = Array.isArray(thread?.turns) ? thread.turns : []
|
|
127
127
|
const all = []
|
|
128
128
|
for (const turn of turns) {
|
|
129
|
-
if (Array.isArray(turn?.items))
|
|
129
|
+
if (!Array.isArray(turn?.items)) continue
|
|
130
|
+
// 表示用バブル時刻 (症状⑧: 履歴 hydrate で全バブルが reload 時刻 = Date.now() になる問題)。
|
|
131
|
+
// app-server の ThreadItem は per-item timestamp を持たない (生成スキーマで確認済み)。
|
|
132
|
+
// ターン単位の時刻 (単位は秒) を各 item に at_ms (ms epoch) として付与する。
|
|
133
|
+
//
|
|
134
|
+
// ⚠️ ライブ経路との整合 (round2 minor): item の種別で使う時刻を変える。
|
|
135
|
+
// - userMessage: ライブでは item/completed.completedAtMs = **ターン冒頭**の時刻で届く
|
|
136
|
+
// (ユーザー入力の echo は即完了するため)。履歴で completedAt(ターン終了) を当てると、
|
|
137
|
+
// 長いターンでリロード後にユーザーバブルが終了時刻へ飛ぶ。→ Turn.startedAt を優先。
|
|
138
|
+
// - それ以外 (agentMessage/tool 等): ライブでは各 item の完了時刻で届くので、
|
|
139
|
+
// ターン終了に近い Turn.completedAt を優先 (無ければ startedAt)。
|
|
140
|
+
const num = (v) => (typeof v === "number" ? v : null)
|
|
141
|
+
const startSec = num(turn.startedAt)
|
|
142
|
+
const endSec = num(turn.completedAt)
|
|
143
|
+
const secFor = (item) =>
|
|
144
|
+
item?.type === "userMessage" ? (startSec ?? endSec) : (endSec ?? startSec)
|
|
145
|
+
for (const item of turn.items) {
|
|
146
|
+
const sec = secFor(item)
|
|
147
|
+
const atMs = sec != null ? Math.round(sec * 1000) : null
|
|
148
|
+
all.push(atMs != null && item && typeof item === "object" ? { ...item, at_ms: atMs } : item)
|
|
149
|
+
}
|
|
130
150
|
}
|
|
131
151
|
const total = all.length
|
|
132
152
|
const kept = []
|
|
@@ -535,10 +555,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
535
555
|
const result = await client.request("thread/list", {
|
|
536
556
|
cwd: cwd || undefined,
|
|
537
557
|
limit: limit || undefined,
|
|
538
|
-
// recency_at
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
|
|
558
|
+
// ⚠️ updated_at を使う (recency_at ではない)。recency_at は「読み込み(=スレッドを
|
|
559
|
+
// 開くだけ)も含めた使用感」の並び順なので、古いスレッドを開いただけでサイドバー最上位
|
|
560
|
+
// に浮上してしまう (症状⑨=実害)。サイドバーは「会話への書き込み(=ターン境界)」で
|
|
561
|
+
// 並べたいので、最終書き込み時刻の updated_at を採用する。
|
|
562
|
+
// (Claude 側 listSessions も jsonl 内の最終メッセージ timestamp = ターン境界に揃えた)
|
|
563
|
+
sortKey: "updated_at",
|
|
542
564
|
sortDirection: "desc",
|
|
543
565
|
})
|
|
544
566
|
const threads = Array.isArray(result?.data) ? result.data : []
|
|
@@ -546,10 +568,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
546
568
|
thread_id: t.id,
|
|
547
569
|
cwd: t.cwd || "",
|
|
548
570
|
preview: typeof t.preview === "string" ? t.preview.slice(0, 80) : "",
|
|
549
|
-
//
|
|
550
|
-
// (ms epoch) と同じ単位に揃え、frontend
|
|
571
|
+
// updatedAt 優先 (無ければ recencyAt fallback) を ms へ変換。claude 側
|
|
572
|
+
// ClaudeSessionSummary.mtime (ms epoch) と同じ単位に揃え、frontend が同じ並び替え
|
|
573
|
+
// ロジックを使い回せるようにする。並び順の source of truth は上の sortKey なので、
|
|
574
|
+
// ここは表示用の mtime も updatedAt を優先させて sortKey と一貫させる。
|
|
551
575
|
updated_at_ms:
|
|
552
|
-
(typeof t.
|
|
576
|
+
(typeof t.updatedAt === "number" ? t.updatedAt : t.recencyAt || 0) * 1000,
|
|
553
577
|
}))
|
|
554
578
|
} finally {
|
|
555
579
|
client.stop()
|