Initial commit

This commit is contained in:
xggz
2026-03-06 22:56:13 +08:00
commit 54d1097b41
273 changed files with 92457 additions and 0 deletions

955
src/lib/session-files.ts Normal file
View 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),
}))
}