Initial commit
This commit is contained in:
955
src/lib/session-files.ts
Normal file
955
src/lib/session-files.ts
Normal file
@@ -0,0 +1,955 @@
|
||||
import type { MessageTurn } from "./types"
|
||||
import { normalizeToolName } from "./tool-call-normalization"
|
||||
|
||||
export type FileOperation = "read" | "edit" | "write" | "apply_patch"
|
||||
|
||||
export interface SessionFileChange {
|
||||
path: string
|
||||
operations: FileOperation[]
|
||||
}
|
||||
|
||||
export interface FileChangeStat {
|
||||
id: string
|
||||
path: string
|
||||
additions: number
|
||||
deletions: number
|
||||
diff: string | null
|
||||
}
|
||||
|
||||
export interface UserMessageGroup {
|
||||
userTurnId: string
|
||||
userMessage: string
|
||||
timestamp: string
|
||||
files: FileChangeStat[]
|
||||
}
|
||||
|
||||
interface DiffStat {
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
|
||||
interface EditChangePreview {
|
||||
oldText: string
|
||||
newText: string
|
||||
unifiedDiff: string | null
|
||||
}
|
||||
|
||||
interface DiffSection extends DiffStat {
|
||||
chunk: string
|
||||
}
|
||||
|
||||
const WRITE_OPS = new Set<string>(["edit", "write", "apply_patch"])
|
||||
const FILE_OPS = new Set<string>(["read", "edit", "write", "apply_patch"])
|
||||
|
||||
const NESTED_PAYLOAD_KEYS = ["input", "arguments", "params", "payload"]
|
||||
|
||||
const PATH_KEYS = [
|
||||
"file_path",
|
||||
"filePath",
|
||||
"path",
|
||||
"target_file",
|
||||
"targetFile",
|
||||
"filename",
|
||||
"notebook_path",
|
||||
] as const
|
||||
|
||||
const PATCH_TEXT_KEYS = [
|
||||
"patch",
|
||||
"content",
|
||||
"diff",
|
||||
"unified_diff",
|
||||
"unifiedDiff",
|
||||
"input",
|
||||
"command",
|
||||
"cmd",
|
||||
"script",
|
||||
] as const
|
||||
|
||||
const EDIT_CHANGE_OLD_KEYS = [
|
||||
"old_string",
|
||||
"oldString",
|
||||
"old_text",
|
||||
"oldText",
|
||||
"old",
|
||||
"before",
|
||||
"source",
|
||||
"original",
|
||||
]
|
||||
|
||||
const EDIT_CHANGE_NEW_KEYS = [
|
||||
"new_string",
|
||||
"newString",
|
||||
"new_text",
|
||||
"newText",
|
||||
"new_content",
|
||||
"newContent",
|
||||
"new",
|
||||
"replacement",
|
||||
"after",
|
||||
"after_text",
|
||||
"afterText",
|
||||
"updated",
|
||||
"updated_text",
|
||||
"updatedText",
|
||||
"content",
|
||||
"new_source",
|
||||
"newSource",
|
||||
"text",
|
||||
]
|
||||
|
||||
const EDIT_CHANGE_DIFF_KEYS = ["diff", "patch", "unified_diff", "unifiedDiff"]
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
function asObjectLike(value: unknown): Record<string, unknown> | null {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
if (typeof value !== "string") return null
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed.startsWith("{")) return null
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
return typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
!Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseInputObject(
|
||||
inputPreview: string | null
|
||||
): Record<string, unknown> | null {
|
||||
if (!inputPreview) return null
|
||||
return asObjectLike(inputPreview)
|
||||
}
|
||||
|
||||
function findStringFieldDeep(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
depth: number = 0
|
||||
): string | null {
|
||||
if (depth > 4) return null
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const found = findStringFieldDeep(item, keys, depth + 1)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const obj = asObjectLike(value)
|
||||
if (!obj) return null
|
||||
|
||||
for (const key of keys) {
|
||||
const direct = obj[key]
|
||||
if (typeof direct === "string" && direct.trim().length > 0) {
|
||||
return direct
|
||||
}
|
||||
}
|
||||
|
||||
for (const nestedKey of NESTED_PAYLOAD_KEYS) {
|
||||
const found = findStringFieldDeep(obj[nestedKey], keys, depth + 1)
|
||||
if (found) return found
|
||||
}
|
||||
|
||||
for (const nestedValue of Object.values(obj)) {
|
||||
const found = findStringFieldDeep(nestedValue, keys, depth + 1)
|
||||
if (found) return found
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function findObjectFieldDeep(
|
||||
value: unknown,
|
||||
key: string,
|
||||
depth: number = 0
|
||||
): Record<string, unknown> | null {
|
||||
if (depth > 4) return null
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const found = findObjectFieldDeep(item, key, depth + 1)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const obj = asObjectLike(value)
|
||||
if (!obj) return null
|
||||
|
||||
const direct = asObjectLike(obj[key])
|
||||
if (direct) return direct
|
||||
|
||||
for (const nestedKey of NESTED_PAYLOAD_KEYS) {
|
||||
const found = findObjectFieldDeep(obj[nestedKey], key, depth + 1)
|
||||
if (found) return found
|
||||
}
|
||||
|
||||
for (const nestedValue of Object.values(obj)) {
|
||||
const found = findObjectFieldDeep(nestedValue, key, depth + 1)
|
||||
if (found) return found
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function firstStringField(
|
||||
value: Record<string, unknown>,
|
||||
keys: readonly string[]
|
||||
): string | null {
|
||||
for (const key of keys) {
|
||||
const field = value[key]
|
||||
if (typeof field === "string") {
|
||||
return field
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function unescapeInlineEscapes(text: string): string {
|
||||
return text
|
||||
.replace(/\\r\\n/g, "\n")
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\t/g, "\t")
|
||||
}
|
||||
|
||||
function decodeJsonEscapedString(value: string): string {
|
||||
return value.replace(/\\"/g, '"').replace(/\\\//g, "/").replace(/\\\\/g, "\\")
|
||||
}
|
||||
|
||||
function normalizeDiffPath(rawPath: string | null): string | null {
|
||||
if (!rawPath) return null
|
||||
|
||||
const trimmed = rawPath.trim()
|
||||
if (!trimmed || trimmed === "/dev/null") return null
|
||||
|
||||
if (trimmed.startsWith("a/") || trimmed.startsWith("b/")) {
|
||||
return normalizePath(trimmed.slice(2))
|
||||
}
|
||||
|
||||
return normalizePath(trimmed)
|
||||
}
|
||||
|
||||
function isDiffAddedLine(line: string): boolean {
|
||||
return line.startsWith("+") && !line.startsWith("+++")
|
||||
}
|
||||
|
||||
function isDiffDeletedLine(line: string): boolean {
|
||||
return line.startsWith("-") && !line.startsWith("---")
|
||||
}
|
||||
|
||||
function countLines(s: string): number {
|
||||
if (!s) return 0
|
||||
return s.split("\n").length
|
||||
}
|
||||
|
||||
function countDiffLines(text: string): DiffStat {
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
|
||||
for (const line of text.split("\n")) {
|
||||
if (isDiffAddedLine(line)) additions += 1
|
||||
if (isDiffDeletedLine(line)) deletions += 1
|
||||
}
|
||||
|
||||
return { additions, deletions }
|
||||
}
|
||||
|
||||
function createHunkHeader(oldLineCount: number, newLineCount: number): string {
|
||||
const oldStart = oldLineCount === 0 ? 0 : 1
|
||||
const newStart = newLineCount === 0 ? 0 : 1
|
||||
return `@@ -${oldStart},${oldLineCount} +${newStart},${newLineCount} @@`
|
||||
}
|
||||
|
||||
function buildUnifiedDiff(
|
||||
filePath: string,
|
||||
oldText: string,
|
||||
newText: string
|
||||
): string | null {
|
||||
if (!oldText && !newText) return null
|
||||
|
||||
const oldLines = oldText ? oldText.split("\n") : []
|
||||
const newLines = newText ? newText.split("\n") : []
|
||||
|
||||
const lines: string[] = [
|
||||
`--- a/${filePath}`,
|
||||
`+++ b/${filePath}`,
|
||||
createHunkHeader(oldLines.length, newLines.length),
|
||||
]
|
||||
|
||||
for (const line of oldLines) lines.push(`-${line}`)
|
||||
for (const line of newLines) lines.push(`+${line}`)
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
function parseEditChangeValue(value: unknown): EditChangePreview | null {
|
||||
if (typeof value === "string") {
|
||||
return {
|
||||
oldText: "",
|
||||
newText: value,
|
||||
unifiedDiff: null,
|
||||
}
|
||||
}
|
||||
|
||||
const record = asObjectLike(value)
|
||||
if (!record) return null
|
||||
|
||||
const oldText =
|
||||
firstStringField(record, EDIT_CHANGE_OLD_KEYS) ??
|
||||
findStringFieldDeep(record, [
|
||||
"old_string",
|
||||
"old_text",
|
||||
"before_text",
|
||||
"old",
|
||||
]) ??
|
||||
""
|
||||
|
||||
const newText =
|
||||
firstStringField(record, EDIT_CHANGE_NEW_KEYS) ??
|
||||
findStringFieldDeep(record, [
|
||||
"new_string",
|
||||
"new_text",
|
||||
"after_text",
|
||||
"new",
|
||||
]) ??
|
||||
""
|
||||
|
||||
const unifiedDiff =
|
||||
firstStringField(record, EDIT_CHANGE_DIFF_KEYS) ??
|
||||
findStringFieldDeep(record, ["diff"]) ??
|
||||
null
|
||||
|
||||
if (!oldText && !newText && !unifiedDiff) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
oldText,
|
||||
newText,
|
||||
unifiedDiff:
|
||||
unifiedDiff && unifiedDiff.trim().length > 0 ? unifiedDiff : null,
|
||||
}
|
||||
}
|
||||
|
||||
function collectEditChangeValues(value: unknown): EditChangePreview[] {
|
||||
if (value === null || value === undefined) return []
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item) => collectEditChangeValues(item))
|
||||
}
|
||||
|
||||
const single = parseEditChangeValue(value)
|
||||
if (single) return [single]
|
||||
|
||||
const record = asObjectLike(value)
|
||||
if (!record) return []
|
||||
|
||||
return Object.values(record).flatMap((item) => collectEditChangeValues(item))
|
||||
}
|
||||
|
||||
function pickValueByNormalizedPath(
|
||||
record: Record<string, unknown>,
|
||||
filePath: string
|
||||
): unknown {
|
||||
if (filePath in record) return record[filePath]
|
||||
|
||||
const escapedPath = filePath.replace(/\//g, "\\/")
|
||||
if (escapedPath in record) return record[escapedPath]
|
||||
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
const normalizedKey = normalizePath(decodeJsonEscapedString(key.trim()))
|
||||
if (normalizedKey === filePath) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function buildChunkFromEditChange(
|
||||
filePath: string,
|
||||
change: EditChangePreview
|
||||
): string | null {
|
||||
if (change.unifiedDiff) {
|
||||
return change.unifiedDiff.trim()
|
||||
}
|
||||
|
||||
return buildUnifiedDiff(filePath, change.oldText, change.newText)
|
||||
}
|
||||
|
||||
function extractPatchTextFromInputPreview(
|
||||
inputPreview: string | null
|
||||
): string | null {
|
||||
if (!inputPreview) return null
|
||||
|
||||
const parsed = parseInputObject(inputPreview)
|
||||
const parsedPatchText = parsed
|
||||
? findStringFieldDeep(parsed, PATCH_TEXT_KEYS)
|
||||
: null
|
||||
|
||||
const candidates = [parsedPatchText, inputPreview]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
|
||||
const normalized = unescapeInlineEscapes(candidate.trim())
|
||||
if (!normalized) continue
|
||||
|
||||
const block = normalized.match(
|
||||
/(\*\*\* Begin Patch[\s\S]*?\*\*\* End Patch(?:\n|$))/m
|
||||
)?.[1]
|
||||
if (block) return block.trim()
|
||||
|
||||
if (normalized.includes("*** Update File:")) return normalized
|
||||
if (/^diff --git /m.test(normalized)) return normalized
|
||||
if (/^--- .+/m.test(normalized) && /^\+\+\+ .+/m.test(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function parseApplyPatchSections(patchText: string): Map<string, DiffSection> {
|
||||
const sections = new Map<string, DiffSection>()
|
||||
|
||||
let currentPath: string | null = null
|
||||
let currentLines: string[] = []
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
|
||||
const flush = () => {
|
||||
if (!currentPath || currentLines.length === 0) return
|
||||
|
||||
const chunk = currentLines.join("\n").trim()
|
||||
if (!chunk) return
|
||||
|
||||
const existing = sections.get(currentPath)
|
||||
if (!existing) {
|
||||
sections.set(currentPath, { chunk, additions, deletions })
|
||||
return
|
||||
}
|
||||
|
||||
sections.set(currentPath, {
|
||||
chunk: `${existing.chunk}\n${chunk}`,
|
||||
additions: existing.additions + additions,
|
||||
deletions: existing.deletions + deletions,
|
||||
})
|
||||
}
|
||||
|
||||
const beginMarkers = [
|
||||
"*** Update File: ",
|
||||
"*** Add File: ",
|
||||
"*** Delete File: ",
|
||||
]
|
||||
|
||||
for (const line of patchText.split("\n")) {
|
||||
const marker = beginMarkers.find((prefix) => line.startsWith(prefix))
|
||||
if (marker) {
|
||||
flush()
|
||||
|
||||
currentPath = normalizeDiffPath(line.slice(marker.length))
|
||||
currentLines = [line]
|
||||
additions = 0
|
||||
deletions = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (!currentPath) continue
|
||||
|
||||
currentLines.push(line)
|
||||
if (line.startsWith("*** Move to: ")) {
|
||||
const movedPath = normalizeDiffPath(line.slice(13))
|
||||
if (movedPath) currentPath = movedPath
|
||||
continue
|
||||
}
|
||||
if (isDiffAddedLine(line)) additions += 1
|
||||
if (isDiffDeletedLine(line)) deletions += 1
|
||||
}
|
||||
|
||||
flush()
|
||||
return sections
|
||||
}
|
||||
|
||||
function parseUnifiedDiffSections(diffText: string): Map<string, DiffSection> {
|
||||
const sections = new Map<string, DiffSection>()
|
||||
|
||||
let currentPath: string | null = null
|
||||
let currentLines: string[] = []
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
|
||||
const flush = () => {
|
||||
if (!currentPath || currentLines.length === 0) return
|
||||
|
||||
const chunk = currentLines.join("\n").trim()
|
||||
if (!chunk) return
|
||||
|
||||
const existing = sections.get(currentPath)
|
||||
if (!existing) {
|
||||
sections.set(currentPath, { chunk, additions, deletions })
|
||||
return
|
||||
}
|
||||
|
||||
sections.set(currentPath, {
|
||||
chunk: `${existing.chunk}\n${chunk}`,
|
||||
additions: existing.additions + additions,
|
||||
deletions: existing.deletions + deletions,
|
||||
})
|
||||
}
|
||||
|
||||
for (const line of diffText.split("\n")) {
|
||||
if (line.startsWith("diff --git ")) {
|
||||
flush()
|
||||
|
||||
const match = line.match(/^diff --git\s+a\/(.+?)\s+b\/(.+)$/)
|
||||
currentPath = normalizeDiffPath(match?.[2] ?? null)
|
||||
currentLines = [line]
|
||||
additions = 0
|
||||
deletions = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("--- ")) {
|
||||
const maybePath = normalizeDiffPath(line.slice(4))
|
||||
if (!currentPath && maybePath) {
|
||||
flush()
|
||||
currentPath = maybePath
|
||||
currentLines = []
|
||||
additions = 0
|
||||
deletions = 0
|
||||
}
|
||||
|
||||
if (currentPath) currentLines.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("+++ ")) {
|
||||
const maybePath = normalizeDiffPath(line.slice(4))
|
||||
if (maybePath && maybePath !== currentPath) {
|
||||
flush()
|
||||
currentPath = maybePath
|
||||
currentLines = []
|
||||
additions = 0
|
||||
deletions = 0
|
||||
}
|
||||
|
||||
if (currentPath) currentLines.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!currentPath) continue
|
||||
|
||||
currentLines.push(line)
|
||||
if (isDiffAddedLine(line)) additions += 1
|
||||
if (isDiffDeletedLine(line)) deletions += 1
|
||||
}
|
||||
|
||||
flush()
|
||||
return sections
|
||||
}
|
||||
|
||||
function extractPathsFromPatchText(patchText: string): string[] {
|
||||
const applyPatchSections = parseApplyPatchSections(patchText)
|
||||
if (applyPatchSections.size > 0) {
|
||||
return Array.from(applyPatchSections.keys())
|
||||
}
|
||||
|
||||
const unifiedDiffSections = parseUnifiedDiffSections(patchText)
|
||||
if (unifiedDiffSections.size > 0) {
|
||||
return Array.from(unifiedDiffSections.keys())
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function extractFilePaths(inputPreview: string | null): string[] {
|
||||
if (!inputPreview) return []
|
||||
|
||||
const paths = new Set<string>()
|
||||
const parsed = parseInputObject(inputPreview)
|
||||
|
||||
if (parsed) {
|
||||
const directPath = findStringFieldDeep(parsed, PATH_KEYS)
|
||||
if (directPath) {
|
||||
paths.add(normalizePath(directPath))
|
||||
}
|
||||
|
||||
const editChanges = findObjectFieldDeep(parsed, "changes")
|
||||
if (editChanges) {
|
||||
for (const path of Object.keys(editChanges)) {
|
||||
const normalized = normalizePath(path.trim())
|
||||
if (normalized) paths.add(normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const patchText = extractPatchTextFromInputPreview(inputPreview)
|
||||
if (patchText) {
|
||||
for (const path of extractPathsFromPatchText(patchText)) {
|
||||
paths.add(path)
|
||||
}
|
||||
}
|
||||
|
||||
if (paths.size === 0) {
|
||||
const match = inputPreview.match(
|
||||
/"(?:file_path|filePath|path|target_file|targetFile|notebook_path)"\s*:\s*"((?:[^"\\]|\\.)+)"/
|
||||
)
|
||||
if (match?.[1]) {
|
||||
paths.add(normalizePath(decodeJsonEscapedString(match[1])))
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(paths)
|
||||
}
|
||||
|
||||
function computeLineDiff(
|
||||
op: string,
|
||||
inputPreview: string | null,
|
||||
filePath?: string
|
||||
): DiffStat | null {
|
||||
if (!inputPreview) return null
|
||||
|
||||
const normalizedPath = filePath ? normalizePath(filePath) : null
|
||||
const parsed = parseInputObject(inputPreview)
|
||||
|
||||
if (op === "edit") {
|
||||
if (parsed && normalizedPath) {
|
||||
const changes = findObjectFieldDeep(parsed, "changes")
|
||||
const changeValue = changes
|
||||
? pickValueByNormalizedPath(changes, normalizedPath)
|
||||
: undefined
|
||||
const changeValues = collectEditChangeValues(changeValue)
|
||||
|
||||
if (changeValues.length > 0) {
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
|
||||
for (const change of changeValues) {
|
||||
if (change.unifiedDiff) {
|
||||
const diff = countDiffLines(change.unifiedDiff)
|
||||
additions += diff.additions
|
||||
deletions += diff.deletions
|
||||
continue
|
||||
}
|
||||
|
||||
additions += countLines(change.newText)
|
||||
deletions += countLines(change.oldText)
|
||||
}
|
||||
|
||||
return { additions, deletions }
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed) return null
|
||||
|
||||
const oldStr =
|
||||
typeof parsed.old_string === "string" ? parsed.old_string : ""
|
||||
const newStr =
|
||||
typeof parsed.new_string === "string" ? parsed.new_string : ""
|
||||
|
||||
if (!oldStr && !newStr) return null
|
||||
|
||||
return {
|
||||
additions: countLines(newStr),
|
||||
deletions: countLines(oldStr),
|
||||
}
|
||||
}
|
||||
|
||||
if (op === "write") {
|
||||
if (!parsed) return null
|
||||
|
||||
const content = typeof parsed.content === "string" ? parsed.content : ""
|
||||
if (!content) return null
|
||||
|
||||
return {
|
||||
additions: countLines(content),
|
||||
deletions: 0,
|
||||
}
|
||||
}
|
||||
|
||||
if (op === "apply_patch") {
|
||||
const patchText = extractPatchTextFromInputPreview(inputPreview)
|
||||
if (!patchText) return null
|
||||
|
||||
const applyPatchSections = parseApplyPatchSections(patchText)
|
||||
if (normalizedPath && applyPatchSections.has(normalizedPath)) {
|
||||
const section = applyPatchSections.get(normalizedPath)
|
||||
if (section) {
|
||||
return {
|
||||
additions: section.additions,
|
||||
deletions: section.deletions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unifiedDiffSections = parseUnifiedDiffSections(patchText)
|
||||
if (normalizedPath && unifiedDiffSections.has(normalizedPath)) {
|
||||
const section = unifiedDiffSections.get(normalizedPath)
|
||||
if (section) {
|
||||
return {
|
||||
additions: section.additions,
|
||||
deletions: section.deletions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const total = countDiffLines(patchText)
|
||||
if (total.additions === 0 && total.deletions === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractUserMessage(turn: MessageTurn): string {
|
||||
for (const block of turn.blocks) {
|
||||
if (block.type === "text" && block.text) {
|
||||
const text = block.text.trim()
|
||||
if (text.length > 80) return `${text.slice(0, 77)}...`
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
return "User message"
|
||||
}
|
||||
|
||||
export function extractSessionFilesGrouped(
|
||||
turns: MessageTurn[]
|
||||
): UserMessageGroup[] {
|
||||
const groups: UserMessageGroup[] = []
|
||||
let currentUserTurn: MessageTurn | null = null
|
||||
let currentFiles: FileChangeStat[] = []
|
||||
|
||||
const flushGroup = () => {
|
||||
if (!currentUserTurn) return
|
||||
if (currentFiles.length === 0) return
|
||||
|
||||
groups.push({
|
||||
userTurnId: currentUserTurn.id,
|
||||
userMessage: extractUserMessage(currentUserTurn),
|
||||
timestamp: currentUserTurn.timestamp,
|
||||
files: currentFiles,
|
||||
})
|
||||
}
|
||||
|
||||
for (const turn of turns) {
|
||||
if (turn.role === "user") {
|
||||
flushGroup()
|
||||
currentUserTurn = turn
|
||||
currentFiles = []
|
||||
continue
|
||||
}
|
||||
|
||||
if (turn.role !== "assistant") continue
|
||||
|
||||
for (const block of turn.blocks) {
|
||||
if (block.type !== "tool_use") continue
|
||||
|
||||
const normalized = normalizeToolName(block.tool_name)
|
||||
if (!WRITE_OPS.has(normalized)) continue
|
||||
|
||||
const filePaths = extractFilePaths(block.input_preview)
|
||||
if (filePaths.length === 0) continue
|
||||
|
||||
for (const [fileIndex, filePath] of filePaths.entries()) {
|
||||
const normalizedPath = normalizePath(filePath)
|
||||
const diff = computeLineDiff(
|
||||
normalized,
|
||||
block.input_preview,
|
||||
normalizedPath
|
||||
)
|
||||
const diffChunk = buildDiffChunk(
|
||||
normalized,
|
||||
block.input_preview,
|
||||
normalizedPath
|
||||
)
|
||||
|
||||
currentFiles.push({
|
||||
id: `${turn.id}:${block.tool_use_id ?? "tool"}:${fileIndex}:${currentFiles.length}`,
|
||||
path: normalizedPath,
|
||||
additions: diff?.additions ?? 0,
|
||||
deletions: diff?.deletions ?? 0,
|
||||
diff: diffChunk?.trim() ? diffChunk.trim() : null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushGroup()
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a unified-diff string for a specific file within a user-message group.
|
||||
* Scans from `userTurnId` forward through assistant turns until the next user
|
||||
* turn, collecting all edit/write/apply_patch operations on `filePath`.
|
||||
*/
|
||||
export function buildSessionFileDiff(
|
||||
turns: MessageTurn[],
|
||||
userTurnId: string,
|
||||
filePath: string
|
||||
): string {
|
||||
let inGroup = false
|
||||
const chunks: string[] = []
|
||||
const normalizedTargetPath = normalizePath(filePath)
|
||||
|
||||
for (const turn of turns) {
|
||||
if (turn.role === "user") {
|
||||
if (turn.id === userTurnId) {
|
||||
inGroup = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (inGroup) {
|
||||
break
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (!inGroup || turn.role !== "assistant") continue
|
||||
|
||||
for (const block of turn.blocks) {
|
||||
if (block.type !== "tool_use") continue
|
||||
|
||||
const normalized = normalizeToolName(block.tool_name)
|
||||
if (!WRITE_OPS.has(normalized)) continue
|
||||
|
||||
const blockPaths = extractFilePaths(block.input_preview).map(
|
||||
normalizePath
|
||||
)
|
||||
if (!blockPaths.includes(normalizedTargetPath)) continue
|
||||
|
||||
const chunk = buildDiffChunk(
|
||||
normalized,
|
||||
block.input_preview,
|
||||
normalizedTargetPath
|
||||
)
|
||||
if (chunk && chunk.trim().length > 0) chunks.push(chunk.trim())
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return `No diff data available for ${filePath}`
|
||||
}
|
||||
|
||||
return chunks.join("\n\n")
|
||||
}
|
||||
|
||||
function buildDiffChunk(
|
||||
op: string,
|
||||
inputPreview: string | null,
|
||||
filePath: string
|
||||
): string | null {
|
||||
if (!inputPreview) return null
|
||||
|
||||
const parsed = parseInputObject(inputPreview)
|
||||
|
||||
if (op === "edit") {
|
||||
if (parsed) {
|
||||
const changes = findObjectFieldDeep(parsed, "changes")
|
||||
const changeValue = changes
|
||||
? pickValueByNormalizedPath(changes, filePath)
|
||||
: undefined
|
||||
const changeValues = collectEditChangeValues(changeValue)
|
||||
|
||||
if (changeValues.length > 0) {
|
||||
const chunks = changeValues
|
||||
.map((change) => buildChunkFromEditChange(filePath, change))
|
||||
.filter((chunk): chunk is string => Boolean(chunk?.trim()))
|
||||
if (chunks.length > 0) return chunks.join("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed) return null
|
||||
|
||||
const oldStr =
|
||||
typeof parsed.old_string === "string" ? parsed.old_string : ""
|
||||
const newStr =
|
||||
typeof parsed.new_string === "string" ? parsed.new_string : ""
|
||||
|
||||
return buildUnifiedDiff(filePath, oldStr, newStr)
|
||||
}
|
||||
|
||||
if (op === "write") {
|
||||
if (!parsed) return null
|
||||
|
||||
const content = typeof parsed.content === "string" ? parsed.content : ""
|
||||
if (!content) return null
|
||||
|
||||
const newLines = content.split("\n")
|
||||
const lines: string[] = [
|
||||
"--- /dev/null",
|
||||
`+++ b/${filePath}`,
|
||||
`@@ -0,0 +1,${newLines.length} @@`,
|
||||
]
|
||||
|
||||
for (const line of newLines) lines.push(`+${line}`)
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
if (op === "apply_patch") {
|
||||
const patchText = extractPatchTextFromInputPreview(inputPreview)
|
||||
if (!patchText) return null
|
||||
|
||||
const applyPatchSections = parseApplyPatchSections(patchText)
|
||||
if (applyPatchSections.has(filePath)) {
|
||||
return applyPatchSections.get(filePath)?.chunk ?? null
|
||||
}
|
||||
|
||||
const unifiedDiffSections = parseUnifiedDiffSections(patchText)
|
||||
if (unifiedDiffSections.has(filePath)) {
|
||||
return unifiedDiffSections.get(filePath)?.chunk ?? null
|
||||
}
|
||||
|
||||
return patchText
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function extractSessionFiles(turns: MessageTurn[]): SessionFileChange[] {
|
||||
const fileMap = new Map<string, Set<FileOperation>>()
|
||||
|
||||
for (const turn of turns) {
|
||||
for (const block of turn.blocks) {
|
||||
if (block.type !== "tool_use") continue
|
||||
|
||||
const normalized = normalizeToolName(block.tool_name)
|
||||
if (!FILE_OPS.has(normalized)) continue
|
||||
|
||||
const filePaths = extractFilePaths(block.input_preview)
|
||||
if (filePaths.length === 0) continue
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
if (!fileMap.has(filePath)) {
|
||||
fileMap.set(filePath, new Set())
|
||||
}
|
||||
|
||||
fileMap.get(filePath)?.add(normalized as FileOperation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(fileMap.entries()).map(([path, operations]) => ({
|
||||
path,
|
||||
operations: Array.from(operations),
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user