Files
codeg/src/lib/permission-request.ts
2026-03-13 13:12:16 +08:00

712 lines
17 KiB
TypeScript

import { normalizeToolName } from "@/lib/tool-call-normalization"
import {
countUnifiedDiffLineChanges,
estimateChangedLineStats,
splitNormalizedLines,
} from "@/lib/line-change-stats"
type ObjectLike = Record<string, unknown>
export interface PermissionFileChange {
path: string
oldText: string
newText: string
unifiedDiff?: string
}
export interface PermissionPlanEntry {
text: string
status: string | null
}
export interface PermissionAllowedPrompt {
prompt: string
tool: string
}
export interface ParsedPermissionToolCall {
title: string
normalizedKind: string
command: string | null
cwd: string | null
fileChanges: PermissionFileChange[]
additions: number
deletions: number
diffPreview: string | null
planEntries: PermissionPlanEntry[]
planExplanation: string | null
planMarkdown: string | null
allowedPrompts: PermissionAllowedPrompt[]
modeTarget: string | null
url: string | null
query: string | null
prompt: string | null
jsonPreview: string
}
function asObject(value: unknown): ObjectLike | null {
if (!value) return null
if (typeof value === "object" && !Array.isArray(value)) {
return value as ObjectLike
}
if (typeof value !== "string") return null
try {
const parsed: unknown = JSON.parse(value)
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as ObjectLike)
: null
} catch {
return null
}
}
function asArray(value: unknown): unknown[] | null {
return Array.isArray(value) ? value : null
}
function pickValue(record: ObjectLike | null, keys: string[]): unknown {
if (!record) return null
for (const key of keys) {
if (!(key in record)) continue
const value = record[key]
if (value !== undefined && value !== null) return value
}
return null
}
function pickString(record: ObjectLike | null, keys: string[]): string | null {
const value = pickValue(record, keys)
if (typeof value !== "string") return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
function joinStringArray(values: unknown): string | null {
if (!Array.isArray(values)) return null
const parts = values.filter(
(item): item is string => typeof item === "string" && item.trim().length > 0
)
return parts.length > 0 ? parts.join(" ") : null
}
function unescapeInlineEscapes(text: string): string {
return text
.replace(/\\r\\n/g, "\n")
.replace(/\\n/g, "\n")
.replace(/\\t/g, "\t")
}
function looksLikeDiffPayload(input: string): boolean {
const normalized = unescapeInlineEscapes(input)
return (
normalized.includes("*** Begin Patch") ||
normalized.includes("*** Update File:") ||
/^diff --git /m.test(normalized) ||
(/^--- .+/m.test(normalized) && /^\+\+\+ .+/m.test(normalized)) ||
/^@@ /m.test(normalized)
)
}
function buildCompactDiffFromTexts(
path: string,
oldText: string,
newText: string,
contextLines: number = 2
): string | null {
const oldLines = splitNormalizedLines(oldText)
const newLines = splitNormalizedLines(newText)
let prefix = 0
while (
prefix < oldLines.length &&
prefix < newLines.length &&
oldLines[prefix] === newLines[prefix]
) {
prefix += 1
}
let suffix = 0
while (
suffix < oldLines.length - prefix &&
suffix < newLines.length - prefix &&
oldLines[oldLines.length - 1 - suffix] ===
newLines[newLines.length - 1 - suffix]
) {
suffix += 1
}
const removed = oldLines.slice(prefix, oldLines.length - suffix)
const added = newLines.slice(prefix, newLines.length - suffix)
if (removed.length === 0 && added.length === 0) return null
const before = oldLines.slice(Math.max(0, prefix - contextLines), prefix)
const after = oldLines.slice(
oldLines.length - suffix,
Math.min(oldLines.length, oldLines.length - suffix + contextLines)
)
const parts: string[] = [`--- ${path}`, `+++ ${path}`]
for (const line of before) parts.push(` ${line}`)
for (const line of removed) parts.push(`-${line}`)
for (const line of added) parts.push(`+${line}`)
for (const line of after) parts.push(` ${line}`)
return parts.join("\n")
}
function buildDiffPreviewFromChanges(
changes: PermissionFileChange[],
maxFiles: number = 8,
maxLines: number = 1200
): string | null {
const meaningful = changes.filter((change) => {
if (
typeof change.unifiedDiff === "string" &&
change.unifiedDiff.trim().length > 0
) {
return true
}
return change.oldText.length > 0 || change.newText.length > 0
})
if (meaningful.length === 0) return null
const limited = meaningful.slice(0, maxFiles)
const lines: string[] = []
let lineCount = 0
let truncated = false
const pushLine = (line: string) => {
if (lineCount >= maxLines) {
truncated = true
return
}
lines.push(line)
lineCount += 1
}
for (const change of limited) {
const block =
typeof change.unifiedDiff === "string" &&
change.unifiedDiff.trim().length > 0
? change.unifiedDiff.trim()
: buildCompactDiffFromTexts(change.path, change.oldText, change.newText)
if (!block) continue
for (const line of block.split("\n")) {
pushLine(line)
if (truncated) break
}
if (truncated) break
pushLine("")
}
if (meaningful.length > limited.length) {
lines.push(`# ... ${meaningful.length - limited.length} more files omitted`)
}
if (truncated) {
lines.push("# ... diff preview truncated")
}
const preview = lines.join("\n").trim()
return preview.length > 0 ? preview : null
}
function stringifyJson(value: unknown): string {
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value ?? "")
}
}
function extractCommandFromUnknownValue(
value: unknown,
depth: number = 0
): string | null {
if (depth > 4 || value === null || value === undefined) return null
if (typeof value === "string") {
const trimmed = value.trim()
if (!trimmed || looksLikeDiffPayload(trimmed)) return null
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
return trimmed
}
try {
const parsed: unknown = JSON.parse(trimmed)
return extractCommandFromUnknownValue(parsed, depth + 1)
} catch {
return null
}
}
if (Array.isArray(value)) {
const joined = joinStringArray(value)
return joined && joined.trim().length > 0 ? joined.trim() : null
}
if (typeof value !== "object") return null
const obj = value as ObjectLike
const directKeys = [
"command",
"cmd",
"script",
"args",
"argv",
"command_args",
]
for (const key of directKeys) {
const direct = extractCommandFromUnknownValue(obj[key], depth + 1)
if (direct) return direct
}
const nestedKeys = [
"rawInput",
"raw_input",
"input",
"arguments",
"params",
"payload",
]
for (const key of nestedKeys) {
const nested = extractCommandFromUnknownValue(obj[key], depth + 1)
if (nested) return nested
}
return null
}
function extractDiffPreview(
rawInput: unknown,
rawInputObj: ObjectLike | null
): string | null {
const candidates: unknown[] = [rawInput]
if (rawInputObj) {
candidates.push(
rawInputObj.patch,
rawInputObj.diff,
rawInputObj.unified_diff,
rawInputObj.unifiedDiff
)
}
for (const candidate of candidates) {
if (typeof candidate !== "string") continue
const normalized = unescapeInlineEscapes(candidate).trim()
if (!normalized) continue
if (looksLikeDiffPayload(normalized)) return normalized
}
return null
}
function parseChangeRecord(
path: string,
value: unknown
): PermissionFileChange | null {
const normalizedPath = path.trim()
if (!normalizedPath) return null
if (typeof value === "string") {
return {
path: normalizedPath,
oldText: "",
newText: value,
unifiedDiff: undefined,
}
}
const record = asObject(value)
if (!record) {
return {
path: normalizedPath,
oldText: "",
newText: "",
}
}
const oldText =
pickString(record, [
"old_string",
"oldString",
"old_text",
"oldText",
"old",
"before",
]) ?? ""
const newText =
pickString(record, [
"new_string",
"newString",
"new_text",
"newText",
"new",
"after",
"content",
"text",
"new_source",
"newSource",
]) ?? ""
const unifiedDiff =
pickString(record, ["unifiedDiff", "unified_diff", "diff", "patch"]) ??
undefined
return {
path: normalizedPath,
oldText,
newText,
unifiedDiff,
}
}
function extractRawInputFileChanges(
rawInputObj: ObjectLike | null
): PermissionFileChange[] {
if (!rawInputObj) return []
const changes: PermissionFileChange[] = []
const byChangesObject = asObject(rawInputObj.changes)
if (byChangesObject) {
for (const [path, value] of Object.entries(byChangesObject)) {
const parsed = parseChangeRecord(path, value)
if (parsed) changes.push(parsed)
}
}
const directPath =
pickString(rawInputObj, [
"file_path",
"filePath",
"path",
"notebook_path",
"target_file",
"targetFile",
]) ?? null
if (directPath) {
const oldText =
pickString(rawInputObj, [
"old_string",
"oldString",
"old_text",
"oldText",
]) ?? ""
const newText =
pickString(rawInputObj, [
"new_string",
"newString",
"new_text",
"newText",
"content",
"text",
"new_source",
]) ?? ""
if (oldText || newText || changes.length === 0) {
changes.push({
path: directPath,
oldText,
newText,
unifiedDiff: undefined,
})
}
}
return changes
}
function extractContentDiffChanges(
toolCallObj: ObjectLike | null
): PermissionFileChange[] {
if (!toolCallObj) return []
const content = asArray(toolCallObj.content)
if (!content) return []
const changes: PermissionFileChange[] = []
for (const item of content) {
const record = asObject(item)
if (!record) continue
const type = pickString(record, ["type"])?.toLowerCase()
if (type !== "diff") continue
const path = pickString(record, ["path"])
if (!path) continue
changes.push({
path,
oldText: pickString(record, ["old_text", "oldText"]) ?? "",
newText: pickString(record, ["new_text", "newText"]) ?? "",
unifiedDiff: undefined,
})
}
return changes
}
function collectLocationPaths(toolCallObj: ObjectLike | null): string[] {
if (!toolCallObj) return []
const locations = asArray(toolCallObj.locations)
if (!locations) return []
const paths: string[] = []
for (const item of locations) {
const record = asObject(item)
if (!record) continue
const path = pickString(record, ["path"])
if (path) paths.push(path)
}
return paths
}
function collectDiffPaths(diffText: string | null): string[] {
if (!diffText) return []
const paths = new Set<string>()
for (const line of diffText.split("\n")) {
if (line.startsWith("*** Add File: ")) {
paths.add(line.slice(14).trim())
continue
}
if (line.startsWith("*** Update File: ")) {
paths.add(line.slice(17).trim())
continue
}
if (line.startsWith("*** Delete File: ")) {
paths.add(line.slice(17).trim())
continue
}
if (line.startsWith("+++ ")) {
const path = line.slice(4).replace(/^b\//, "").trim()
if (path && path !== "/dev/null") paths.add(path)
}
}
return Array.from(paths)
}
function mergeFileChanges(
changes: PermissionFileChange[]
): PermissionFileChange[] {
const merged = new Map<string, PermissionFileChange>()
for (const change of changes) {
const path = change.path.trim()
if (!path) continue
const prev = merged.get(path)
if (!prev) {
merged.set(path, { ...change, path })
continue
}
const oldText = prev.oldText || change.oldText
const newText = prev.newText || change.newText
const unifiedDiff = prev.unifiedDiff || change.unifiedDiff
merged.set(path, { path, oldText, newText, unifiedDiff })
}
return Array.from(merged.values())
}
function parsePlanEntries(
rawInputObj: ObjectLike | null
): PermissionPlanEntry[] {
if (!rawInputObj) return []
const candidates = [
pickValue(rawInputObj, ["plan"]),
pickValue(rawInputObj, ["entries"]),
pickValue(rawInputObj, ["steps"]),
pickValue(rawInputObj, ["todos"]),
]
for (const candidate of candidates) {
const list = asArray(candidate)
if (!list || list.length === 0) continue
const entries: PermissionPlanEntry[] = []
for (const item of list) {
const record = asObject(item)
if (!record) continue
const text =
pickString(record, [
"step",
"content",
"title",
"task",
"description",
]) ?? null
if (!text) continue
entries.push({
text,
status: pickString(record, ["status", "state"]),
})
}
if (entries.length > 0) return entries
}
return []
}
function parseAllowedPrompts(
rawInputObj: ObjectLike | null
): PermissionAllowedPrompt[] {
if (!rawInputObj) return []
const list = asArray(
pickValue(rawInputObj, ["allowedPrompts", "allowed_prompts"])
)
if (!list || list.length === 0) return []
const prompts: PermissionAllowedPrompt[] = []
for (const item of list) {
const record = asObject(item)
if (!record) continue
const prompt = pickString(record, ["prompt", "description", "text"])
const tool = pickString(record, ["tool", "toolName", "tool_name"])
if (prompt) {
prompts.push({ prompt, tool: tool ?? "" })
}
}
return prompts
}
function formatFallbackTitle(kind: string): string {
const normalized = kind.replace(/_/g, " ").trim()
if (!normalized) return "Permission Request"
return normalized
.split(/\s+/)
.map((part) => part[0].toUpperCase() + part.slice(1))
.join(" ")
}
export function parsePermissionToolCall(
toolCall: unknown
): ParsedPermissionToolCall {
const toolCallObj = asObject(toolCall)
const rawKind =
pickString(toolCallObj, [
"kind",
"tool_name",
"toolName",
"name",
"type",
]) ?? "tool"
const normalizedKind = normalizeToolName(rawKind)
const rawInputValue =
pickValue(toolCallObj, [
"rawInput",
"raw_input",
"input",
"arguments",
"params",
"payload",
]) ?? null
const rawInputObj = asObject(rawInputValue)
const command =
extractCommandFromUnknownValue(rawInputValue) ??
extractCommandFromUnknownValue(toolCallObj)
const cwd =
pickString(rawInputObj, [
"cwd",
"workdir",
"working_directory",
"workingDirectory",
]) ??
pickString(toolCallObj, [
"cwd",
"workdir",
"working_directory",
"workingDirectory",
])
const explicitDiffPreview = extractDiffPreview(rawInputValue, rawInputObj)
const rawInputFileChanges = extractRawInputFileChanges(rawInputObj)
const contentDiffChanges = extractContentDiffChanges(toolCallObj)
const locationPaths = collectLocationPaths(toolCallObj)
const diffPaths = collectDiffPaths(explicitDiffPreview)
const combinedChanges = mergeFileChanges([
...rawInputFileChanges,
...contentDiffChanges,
...locationPaths.map((path) => ({
path,
oldText: "",
newText: "",
unifiedDiff: undefined,
})),
...diffPaths.map((path) => ({
path,
oldText: "",
newText: "",
unifiedDiff: undefined,
})),
])
const diffPreview =
explicitDiffPreview ?? buildDiffPreviewFromChanges(combinedChanges)
let additions = 0
let deletions = 0
if (diffPreview) {
const stats = countUnifiedDiffLineChanges(diffPreview)
additions = stats.additions
deletions = stats.deletions
} else {
for (const change of combinedChanges) {
if (
typeof change.unifiedDiff === "string" &&
change.unifiedDiff.trim().length > 0
) {
const stats = countUnifiedDiffLineChanges(change.unifiedDiff)
additions += stats.additions
deletions += stats.deletions
continue
}
const stats = estimateChangedLineStats(change.oldText, change.newText)
additions += stats.additions
deletions += stats.deletions
}
}
const planEntries = parsePlanEntries(rawInputObj)
const planExplanation = pickString(rawInputObj, ["explanation"])
const rawPlan = rawInputObj ? pickValue(rawInputObj, ["plan"]) : null
const planMarkdown =
typeof rawPlan === "string" && rawPlan.trim().length > 0 ? rawPlan : null
const allowedPrompts = parseAllowedPrompts(rawInputObj)
const modeTarget =
pickString(rawInputObj, [
"mode_id",
"modeId",
"target_mode",
"targetMode",
]) ?? null
const url =
pickString(rawInputObj, ["url"]) ?? pickString(toolCallObj, ["url"])
const query =
pickString(rawInputObj, ["query"]) ?? pickString(toolCallObj, ["query"])
const prompt =
pickString(rawInputObj, ["prompt"]) ?? pickString(toolCallObj, ["prompt"])
const title =
pickString(toolCallObj, ["title", "tool_name", "toolName", "name"]) ??
formatFallbackTitle(normalizedKind)
return {
title,
normalizedKind,
command,
cwd,
fileChanges: combinedChanges,
additions,
deletions,
diffPreview,
planEntries,
planExplanation,
planMarkdown,
allowedPrompts,
modeTarget,
url,
query,
prompt,
jsonPreview: stringifyJson(toolCallObj ?? toolCall),
}
}