@cocorograph/hub-agent 0.8.9 → 0.8.11

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.
@@ -0,0 +1,362 @@
1
+ import { open, readdir, realpath, stat } from "node:fs/promises"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+
5
+ import { extractLastTurnTexts, jsonlPath, LAST_TURN_TEXT_MAX } from "./claude-history.mjs"
6
+ import { resolveProjectsRoot } from "./profiles.mjs"
7
+ import * as codexProfiles from "./codex-profiles.mjs"
8
+ import { lookupInjectSource } from "./inject-source-store.mjs"
9
+ import { SECRET_PLACEHOLDER, redactSecrets } from "./tool-result-mask.mjs"
10
+
11
+ const MAX_TARGETS = 100
12
+ const READ_CONCURRENCY = 8
13
+ const TAIL_CHUNK = 64 * 1024
14
+ const MAX_SCAN = 8 * 1024 * 1024
15
+ const MAX_LINE = 1024 * 1024
16
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
17
+
18
+ const textCache = new Map()
19
+ const textInFlight = new Map()
20
+ const rolloutPathCache = new Map()
21
+
22
+ function sanitizeText(value) {
23
+ if (typeof value !== "string" || !value.trim()) return ""
24
+ let masked = redactSecrets(value)
25
+ const openerAt = masked.indexOf("-----BEGIN")
26
+ if (openerAt >= 0) masked = masked.slice(0, openerAt) + SECRET_PLACEHOLDER
27
+ return masked.trim().replace(/\s+/g, " ").slice(0, LAST_TURN_TEXT_MAX)
28
+ }
29
+
30
+ function itemText(item) {
31
+ const content = item?.content
32
+ if (typeof content === "string") return content
33
+ if (!Array.isArray(content)) return ""
34
+ return content
35
+ .map((block) =>
36
+ block && typeof block === "object" && typeof block.text === "string"
37
+ ? block.text
38
+ : "",
39
+ )
40
+ .join("\n")
41
+ }
42
+
43
+ export async function textsFromCodexItems(items, { isInjected } = {}) {
44
+ let assistant = ""
45
+ if (!Array.isArray(items)) return { user: "", assistant }
46
+ for (let i = items.length - 1; i >= 0; i--) {
47
+ const item = items[i]
48
+ if (item?.type === "agentMessage" && !assistant) {
49
+ assistant = sanitizeText(itemText(item))
50
+ continue
51
+ }
52
+ if (item?.type !== "userMessage") continue
53
+ const user = sanitizeText(itemText(item))
54
+ if (!user) continue
55
+ let injected = item.source === "peer" || item.source === "orchestrator"
56
+ if (!injected && typeof isInjected === "function") {
57
+ try {
58
+ injected = (await isInjected(user)) === true
59
+ } catch {
60
+ // 判定不能なら通常入力として残す。
61
+ }
62
+ }
63
+ if (injected) {
64
+ assistant = ""
65
+ continue
66
+ }
67
+ return { user, assistant }
68
+ }
69
+ return { user: "", assistant }
70
+ }
71
+
72
+ function codexLineMessage(line) {
73
+ if (!line) return null
74
+ let obj
75
+ try {
76
+ obj = JSON.parse(line)
77
+ } catch {
78
+ return null
79
+ }
80
+ if (obj?.type !== "event_msg" || !obj.payload) return null
81
+ if (obj.payload.type === "agent_message") {
82
+ return { role: "assistant", text: sanitizeText(obj.payload.message) }
83
+ }
84
+ if (obj.payload.type === "user_message") {
85
+ return { role: "user", text: sanitizeText(obj.payload.message) }
86
+ }
87
+ return null
88
+ }
89
+
90
+ export async function extractCodexLastTurnTexts(filePath, { isInjected } = {}) {
91
+ let assistant = ""
92
+ let handle
93
+ try {
94
+ handle = await open(filePath, "r")
95
+ const fileStat = await handle.stat()
96
+ let pos = fileStat.size
97
+ let carry = null
98
+ let scanned = 0
99
+ while (pos > 0 && scanned < MAX_SCAN) {
100
+ const len = Math.min(TAIL_CHUNK, pos, MAX_SCAN - scanned)
101
+ pos -= len
102
+ const buf = Buffer.allocUnsafe(len)
103
+ const { bytesRead } = await handle.read(buf, 0, len, pos)
104
+ scanned += bytesRead
105
+ const region = carry?.length
106
+ ? Buffer.concat([buf.subarray(0, bytesRead), carry])
107
+ : buf.subarray(0, bytesRead)
108
+ const parts = []
109
+ let start = 0
110
+ for (let i = 0; i < region.length; i++) {
111
+ if (region[i] === 0x0a) {
112
+ parts.push(region.subarray(start, i))
113
+ start = i + 1
114
+ }
115
+ }
116
+ parts.push(region.subarray(start))
117
+ carry = pos > 0 ? parts.shift() : null
118
+ if (carry?.length > MAX_LINE) carry = null
119
+
120
+ for (let i = parts.length - 1; i >= 0; i--) {
121
+ if (parts[i].length === 0 || parts[i].length > MAX_LINE) continue
122
+ const message = codexLineMessage(parts[i].toString("utf8"))
123
+ if (!message?.text) continue
124
+ if (message.role === "assistant") {
125
+ if (!assistant) assistant = message.text
126
+ continue
127
+ }
128
+ let injected = false
129
+ if (typeof isInjected === "function") {
130
+ try {
131
+ injected = (await isInjected(message.text)) === true
132
+ } catch {
133
+ // 判定不能なら通常入力として残す。
134
+ }
135
+ }
136
+ if (injected) {
137
+ assistant = ""
138
+ continue
139
+ }
140
+ return { user: message.text, assistant }
141
+ }
142
+ }
143
+ return { user: "", assistant }
144
+ } catch {
145
+ return { user: "", assistant: "" }
146
+ } finally {
147
+ await handle?.close().catch(() => {})
148
+ }
149
+ }
150
+
151
+ async function findCodexRolloutFile(threadId, sessionsDir) {
152
+ if (!UUID_RE.test(threadId) || !sessionsDir) return null
153
+ const cacheKey = `${sessionsDir}\0${threadId}`
154
+ const cached = rolloutPathCache.get(cacheKey)
155
+ if (cached) {
156
+ try {
157
+ await stat(cached)
158
+ return cached
159
+ } catch {
160
+ rolloutPathCache.delete(cacheKey)
161
+ }
162
+ }
163
+ const suffix = `-${threadId}.jsonl`
164
+ const years = await readdir(sessionsDir).catch(() => [])
165
+ for (const year of years.filter((v) => /^\d{4}$/.test(v)).sort().reverse()) {
166
+ const yearDir = path.join(sessionsDir, year)
167
+ const months = await readdir(yearDir).catch(() => [])
168
+ for (const month of months.sort().reverse()) {
169
+ const monthDir = path.join(yearDir, month)
170
+ const days = await readdir(monthDir).catch(() => [])
171
+ for (const day of days.sort().reverse()) {
172
+ const dayDir = path.join(monthDir, day)
173
+ const files = await readdir(dayDir).catch(() => [])
174
+ const match = files.find((file) => file.endsWith(suffix))
175
+ if (!match) continue
176
+ const found = path.join(dayDir, match)
177
+ rolloutPathCache.set(cacheKey, found)
178
+ return found
179
+ }
180
+ }
181
+ }
182
+ return null
183
+ }
184
+
185
+ async function readCached(filePath, extract, { maxAgeMs = Infinity } = {}) {
186
+ if (!filePath) return { user: "", assistant: "" }
187
+ let fileStat
188
+ try {
189
+ fileStat = await stat(filePath)
190
+ } catch {
191
+ return { user: "", assistant: "" }
192
+ }
193
+ const version = `${fileStat.mtimeMs}:${fileStat.size}:${fileStat.ctimeMs}`
194
+ const cached = textCache.get(filePath)
195
+ if (
196
+ cached &&
197
+ cached.version === version &&
198
+ Date.now() - cached.checkedAt < maxAgeMs
199
+ ) {
200
+ return cached.texts
201
+ }
202
+
203
+ // 複数タブの同時 snapshot は同じ版の抽出 1 本へ畳む。版が違う要求は古い
204
+ // in-flight に合流させず、追記後の本文を独立して読む。
205
+ const inFlightKey = `${filePath}\0${version}`
206
+ let pending = textInFlight.get(inFlightKey)
207
+ if (!pending) {
208
+ pending = extract(filePath).finally(() => {
209
+ textInFlight.delete(inFlightKey)
210
+ })
211
+ textInFlight.set(inFlightKey, pending)
212
+ }
213
+ const texts = await pending
214
+
215
+ // 抽出中に追記・置換された結果を開始時の版としてキャッシュしない。
216
+ try {
217
+ const after = await stat(filePath)
218
+ const afterVersion = `${after.mtimeMs}:${after.size}:${after.ctimeMs}`
219
+ if (afterVersion === version) {
220
+ textCache.set(filePath, {
221
+ version,
222
+ checkedAt: Date.now(),
223
+ texts,
224
+ })
225
+ }
226
+ } catch {
227
+ // 読み終えた直後の削除は今回結果だけ返し、cache には残さない。
228
+ }
229
+ while (textCache.size > 512) textCache.delete(textCache.keys().next().value)
230
+ return texts
231
+ }
232
+
233
+ function normalizedTarget(raw) {
234
+ if (!raw || typeof raw !== "object") return null
235
+ const sessionName = typeof raw.session_name === "string" ? raw.session_name.trim() : ""
236
+ const cwd = typeof raw.cwd === "string" ? raw.cwd.trim() : ""
237
+ const cliKind = raw.cli_kind === "codex" ? "codex" : raw.cli_kind === "claude" ? "claude" : ""
238
+ const conversationId =
239
+ typeof raw.conversation_id === "string" ? raw.conversation_id.trim() : ""
240
+ const profileId = typeof raw.profile_id === "string" ? raw.profile_id.trim() : undefined
241
+ if (!sessionName || !cwd || !cliKind || !UUID_RE.test(conversationId)) return null
242
+ return { sessionName, cwd, cliKind, conversationId, profileId }
243
+ }
244
+
245
+ async function defaultIsAllowedCwd(cwd) {
246
+ const configuredBase = process.env.HUB_PROJECTS_BASE || path.join(os.homedir(), "hub", "projects")
247
+ try {
248
+ const [resolvedCwd, resolvedBase] = await Promise.all([realpath(cwd), realpath(configuredBase)])
249
+ if (!resolvedCwd.startsWith(`${resolvedBase}${path.sep}`)) return false
250
+ const parts = path.relative(resolvedBase, resolvedCwd).split(path.sep)
251
+ return (
252
+ parts.length === 1 ||
253
+ (parts.length === 4 && parts[1] === ".claude" && parts[2] === "worktrees")
254
+ )
255
+ } catch {
256
+ return false
257
+ }
258
+ }
259
+
260
+ export async function readSidebarMessageSnapshot(
261
+ targets,
262
+ {
263
+ resolveClaudeRoot = resolveProjectsRoot,
264
+ resolveCodexRoot = codexProfiles.resolveSessionsDir,
265
+ liveCodexSession,
266
+ isAllowedCwd = defaultIsAllowedCwd,
267
+ lookupInjected = lookupInjectSource,
268
+ extractClaude = extractLastTurnTexts,
269
+ extractCodex = extractCodexLastTurnTexts,
270
+ } = {},
271
+ ) {
272
+ const safeTargets = (Array.isArray(targets) ? targets : [])
273
+ .slice(0, MAX_TARGETS)
274
+ .map(normalizedTarget)
275
+ .filter(Boolean)
276
+
277
+ const readTarget = async (target) => {
278
+ try {
279
+ if (!(await isAllowedCwd(target.cwd))) throw new Error("cwd outside workspace roots")
280
+ const isInjected = async (text) =>
281
+ Boolean(await lookupInjected(target.cwd, text))
282
+ let texts = { user: "", assistant: "" }
283
+ if (target.cliKind === "claude") {
284
+ const { projectsRoot, profileId } = await resolveClaudeRoot(target.profileId)
285
+ const filePath = jsonlPath({
286
+ cwd: target.cwd,
287
+ session_id: target.conversationId,
288
+ projectsRoot,
289
+ })
290
+ texts = await readCached(
291
+ filePath,
292
+ (fp) => extractClaude(fp, { isInjected }),
293
+ { maxAgeMs: 2_000 },
294
+ )
295
+ return {
296
+ session_name: target.sessionName,
297
+ cli_kind: "claude",
298
+ conversation_id: target.conversationId,
299
+ profile_id: profileId,
300
+ ...(texts.user ? { user: texts.user } : {}),
301
+ ...(texts.assistant ? { assistant: texts.assistant } : {}),
302
+ }
303
+ }
304
+
305
+ const live = liveCodexSession?.(target)
306
+ if (live && live.threadId === target.conversationId) {
307
+ texts = await textsFromCodexItems(live.historyItems, { isInjected })
308
+ }
309
+ const { sessionsDir, profileId } = await resolveCodexRoot(target.profileId)
310
+ if (!texts.user || !texts.assistant) {
311
+ const filePath = await findCodexRolloutFile(target.conversationId, sessionsDir)
312
+ const diskTexts = await readCached(
313
+ filePath,
314
+ (fp) => extractCodex(fp, { isInjected }),
315
+ { maxAgeMs: 2_000 },
316
+ )
317
+ texts = {
318
+ user: texts.user || diskTexts.user,
319
+ // live に user があるのに assistant が無い = 生成中。前ターンの disk assistant
320
+ // で埋めると別ターン混在になるため、disk は live user 自体が無いときだけ使う。
321
+ assistant: texts.user ? texts.assistant : diskTexts.assistant,
322
+ }
323
+ }
324
+ return {
325
+ session_name: target.sessionName,
326
+ cli_kind: "codex",
327
+ conversation_id: target.conversationId,
328
+ profile_id: profileId,
329
+ ...(texts.user ? { user: texts.user } : {}),
330
+ ...(texts.assistant ? { assistant: texts.assistant } : {}),
331
+ }
332
+ } catch {
333
+ // 1 件の profile / 履歴 I/O 失敗で、他セッションの表示まで消さない。
334
+ return {
335
+ session_name: target.sessionName,
336
+ cli_kind: target.cliKind,
337
+ conversation_id: target.conversationId,
338
+ }
339
+ }
340
+ }
341
+
342
+ const messages = new Array(safeTargets.length)
343
+ let cursor = 0
344
+ const workers = Array.from(
345
+ { length: Math.min(READ_CONCURRENCY, safeTargets.length) },
346
+ async () => {
347
+ for (;;) {
348
+ const index = cursor++
349
+ if (index >= safeTargets.length) return
350
+ messages[index] = await readTarget(safeTargets[index])
351
+ }
352
+ },
353
+ )
354
+ await Promise.all(workers)
355
+ return messages
356
+ }
357
+
358
+ export function _resetSidebarMessageSnapshotCachesForTest() {
359
+ textCache.clear()
360
+ textInFlight.clear()
361
+ rolloutPathCache.clear()
362
+ }