Merge pull request #70 from y574444354/feat/server-refactor-90331
feat(server): support reading subagent transcripts
This commit is contained in:
commit
daa2dce7b9
|
|
@ -88,6 +88,10 @@ export function routeMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
||||||
ctx.emitEvent('stream_event', msg)
|
ctx.emitEvent('stream_event', msg)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'system': {
|
||||||
|
ctx.emitEvent('message', msg)
|
||||||
|
break
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,142 @@ function parseEntry(line: string): TranscriptEntry | null {
|
||||||
return parsed as TranscriptEntry
|
return parsed as TranscriptEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function entryToSessionMessage(
|
||||||
|
entry: TranscriptEntry,
|
||||||
|
includeSystem: boolean,
|
||||||
|
): SessionMessage | null {
|
||||||
|
if (!entry.type || !entry.uuid) return null
|
||||||
|
if (SKIP_TYPES.has(entry.type)) return null
|
||||||
|
if (entry.type === 'attribution-snapshot') return null
|
||||||
|
|
||||||
|
const includeThis =
|
||||||
|
MESSAGE_TYPES.has(entry.type) ||
|
||||||
|
(includeSystem && entry.type === 'system')
|
||||||
|
|
||||||
|
if (!includeThis) return null
|
||||||
|
|
||||||
|
const role = entry.role ?? entry.message?.role ?? entry.type
|
||||||
|
const content = entry.message?.content ?? entry.content ?? ''
|
||||||
|
const timestamp = entry.timestamp
|
||||||
|
? new Date(entry.timestamp).getTime()
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
uuid: entry.uuid,
|
||||||
|
type: entry.type,
|
||||||
|
role: role ?? entry.type,
|
||||||
|
content,
|
||||||
|
timestamp,
|
||||||
|
parent_uuid: entry.parentUuid ?? null,
|
||||||
|
usage: entry.usage as
|
||||||
|
| { input_tokens: number; output_tokens: number }
|
||||||
|
| undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMessagesFromLines(
|
||||||
|
lines: string[],
|
||||||
|
includeSystem: boolean,
|
||||||
|
): SessionMessage[] {
|
||||||
|
const messages: SessionMessage[] = []
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line) continue
|
||||||
|
const entry = parseEntry(line)
|
||||||
|
if (!entry) continue
|
||||||
|
const msg = entryToSessionMessage(entry, includeSystem)
|
||||||
|
if (msg) messages.push(msg)
|
||||||
|
}
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locate the subagent transcript file for a given agentId by scanning
|
||||||
|
* `<projectsDir>/<project>/<parentSessionId>/subagents/agent-<agentId>.jsonl`.
|
||||||
|
*
|
||||||
|
* `cwd` is used to narrow the search to a specific project dir; if omitted or
|
||||||
|
* not matched, we fall back to scanning every project directory.
|
||||||
|
*/
|
||||||
|
async function findSubagentTranscriptPath(
|
||||||
|
agentId: string,
|
||||||
|
cwd?: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const fileName = `agent-${agentId}.jsonl`
|
||||||
|
|
||||||
|
const searchProject = async (projectDir: string): Promise<string | null> => {
|
||||||
|
let sessionDirs: string[]
|
||||||
|
try {
|
||||||
|
sessionDirs = await readdir(projectDir)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
for (const sessionDir of sessionDirs) {
|
||||||
|
const direct = join(projectDir, sessionDir, 'subagents', fileName)
|
||||||
|
try {
|
||||||
|
const s = await stat(direct)
|
||||||
|
if (s.isFile() && s.size > 0) return direct
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
const nestedSubagentsDir = join(projectDir, sessionDir, 'subagents')
|
||||||
|
let subdirs: string[]
|
||||||
|
try {
|
||||||
|
subdirs = await readdir(nestedSubagentsDir)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const sub of subdirs) {
|
||||||
|
const candidate = join(nestedSubagentsDir, sub, fileName)
|
||||||
|
try {
|
||||||
|
const s = await stat(candidate)
|
||||||
|
if (s.isFile() && s.size > 0) return candidate
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cwd) {
|
||||||
|
const projectDir = getProjectDir(cwd)
|
||||||
|
const hit = await searchProject(projectDir)
|
||||||
|
if (hit) return hit
|
||||||
|
|
||||||
|
const found = await findProjectDir(cwd)
|
||||||
|
if (found && found !== projectDir) {
|
||||||
|
const hit2 = await searchProject(found)
|
||||||
|
if (hit2) return hit2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectsDir = getProjectsDir()
|
||||||
|
let dirs: string[]
|
||||||
|
try {
|
||||||
|
dirs = await readdir(projectsDir)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
for (const dir of dirs) {
|
||||||
|
const hit = await searchProject(join(projectsDir, dir))
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSubagentMessages(
|
||||||
|
agentId: string,
|
||||||
|
cwd: string | undefined,
|
||||||
|
includeSystem: boolean,
|
||||||
|
): Promise<SessionMessage[]> {
|
||||||
|
const path = await findSubagentTranscriptPath(agentId, cwd)
|
||||||
|
if (!path) return []
|
||||||
|
try {
|
||||||
|
const raw = await readFile(path, 'utf-8')
|
||||||
|
const lines = raw.split('\n')
|
||||||
|
return readMessagesFromLines(lines, includeSystem)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function readSessionMessages(opts: {
|
export async function readSessionMessages(opts: {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
cwd?: string
|
cwd?: string
|
||||||
|
|
@ -122,66 +258,43 @@ export async function readSessionMessages(opts: {
|
||||||
messages: SessionMessage[]
|
messages: SessionMessage[]
|
||||||
nextCursor?: string
|
nextCursor?: string
|
||||||
}> {
|
}> {
|
||||||
|
const includeSystem = opts.includeSystem ?? false
|
||||||
const path = await resolveTranscriptPath(opts.sessionId, opts.cwd)
|
const path = await resolveTranscriptPath(opts.sessionId, opts.cwd)
|
||||||
if (!path || !existsSync(path)) {
|
|
||||||
return { messages: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = await readFile(path, 'utf-8')
|
let mainMessages: SessionMessage[] = []
|
||||||
const lines = raw.split('\n').filter(Boolean)
|
if (path && existsSync(path)) {
|
||||||
|
const raw = await readFile(path, 'utf-8')
|
||||||
|
const lines = raw.split('\n').filter(Boolean)
|
||||||
|
|
||||||
let lastCompactBoundaryIndex = -1
|
let lastCompactBoundaryIndex = -1
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
const entry = parseEntry(lines[i])
|
const entry = parseEntry(lines[i])
|
||||||
if (
|
if (
|
||||||
entry?.type === 'system' &&
|
entry?.type === 'system' &&
|
||||||
entry?.subtype === 'compact_boundary' &&
|
entry?.subtype === 'compact_boundary' &&
|
||||||
!entry?.compactMetadata?.preservedSegment
|
!entry?.compactMetadata?.preservedSegment
|
||||||
) {
|
) {
|
||||||
lastCompactBoundaryIndex = i
|
lastCompactBoundaryIndex = i
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startLine = lastCompactBoundaryIndex >= 0 ? lastCompactBoundaryIndex + 1 : 0
|
||||||
|
mainMessages = readMessagesFromLines(lines.slice(startLine), includeSystem)
|
||||||
|
} else {
|
||||||
|
// Fallback: 视 sessionId 为 agentId,读取 subagent transcript。
|
||||||
|
mainMessages = await readSubagentMessages(opts.sessionId, opts.cwd, includeSystem)
|
||||||
|
if (mainMessages.length === 0) {
|
||||||
|
return { messages: [] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const startLine = lastCompactBoundaryIndex >= 0 ? lastCompactBoundaryIndex + 1 : 0
|
const allMessages = mainMessages
|
||||||
|
allMessages.sort((a, b) => a.timestamp - b.timestamp)
|
||||||
|
|
||||||
const allMessages: SessionMessage[] = []
|
|
||||||
let cursorIndex = -1
|
let cursorIndex = -1
|
||||||
|
if (opts.before) {
|
||||||
for (let i = startLine; i < lines.length; i++) {
|
cursorIndex = allMessages.findIndex(m => m.uuid === opts.before)
|
||||||
const entry = parseEntry(lines[i])
|
|
||||||
if (!entry) continue
|
|
||||||
if (!entry.type || !entry.uuid) continue
|
|
||||||
if (SKIP_TYPES.has(entry.type)) continue
|
|
||||||
if (entry.type === 'attribution-snapshot') continue
|
|
||||||
|
|
||||||
const includeThis =
|
|
||||||
MESSAGE_TYPES.has(entry.type) ||
|
|
||||||
(opts.includeSystem && entry.type === 'system')
|
|
||||||
|
|
||||||
if (!includeThis) continue
|
|
||||||
|
|
||||||
if (entry.uuid === opts.before) {
|
|
||||||
cursorIndex = allMessages.length
|
|
||||||
}
|
|
||||||
|
|
||||||
const role = entry.role ?? entry.message?.role ?? entry.type
|
|
||||||
const content = entry.message?.content ?? entry.content ?? ''
|
|
||||||
const timestamp = entry.timestamp
|
|
||||||
? new Date(entry.timestamp).getTime()
|
|
||||||
: 0
|
|
||||||
|
|
||||||
allMessages.push({
|
|
||||||
uuid: entry.uuid,
|
|
||||||
type: entry.type,
|
|
||||||
role: role ?? entry.type,
|
|
||||||
content,
|
|
||||||
timestamp,
|
|
||||||
parent_uuid: entry.parentUuid ?? null,
|
|
||||||
usage: entry.usage as
|
|
||||||
| { input_tokens: number; output_tokens: number }
|
|
||||||
| undefined,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let sliced: SessionMessage[]
|
let sliced: SessionMessage[]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user