Clear.ps1

# 清理层:cct clear —— 清 cct 扫描缓存;cct clear -cc —— 清理多余 Claude Code 会话。
# 判定规则源自 2026-09-04 真实清理经验(与显示层 Get-CctTasks 的语义刻意分离):
# 对 projects 根下每个编码目录:会话 = 该目录内 *.jsonl(排除 journal.jsonl / agent-*,与数据层一致)。
# 「对话次数」按 .jsonl 行数近似(= 消息事件行数,规则同 wc -l)。
# 每目录保留「行数 ≥ 阈值」里最新 keep 个(按文件修改时间降序、行数降序决胜);
# 目录内无 ≥ 阈值的会话 → 整目录删除(含 memory 与孤立残留);
# 被删会话的同名附属目录(subagents/tool-results/workflows)与孤立目录一并列入删除。
# 删除不可逆:先打印「过滤规则 + 待删清单 + 理由」,确认后才删;非交互且未给 --yes 只预览不删。

# 字节数人性化(预览用)
function Format-CctBytes {
    param([int64]$Bytes)
    if ($Bytes -ge 1MB) { return ('{0:N1} MB' -f ($Bytes / 1MB)) }
    if ($Bytes -ge 1KB) { return ('{0:N0} KB' -f ($Bytes / 1KB)) }
    return "$Bytes B"
}

# 会话文件行数(行流迭代,不整体载入内存);读取失败返回 -1(不可判 → 调用方不判不删)
function Get-CctLineCount {
    param([string]$Path)
    try {
        $n = 0
        foreach ($_ in [System.IO.File]::ReadLines($Path)) { $n++ }
        return $n
    } catch { return -1 }
}

# 删除项占用大小(字节:目录含全部子文件;估算回收空间用)
function Get-CctCleanupSize {
    param([string]$Path)
    try {
        if ([System.IO.Directory]::Exists($Path)) {
            $sum = 0L
            foreach ($f in [System.IO.Directory]::EnumerateFiles($Path, '*', [System.IO.SearchOption]::AllDirectories)) {
                $sum += [System.IO.FileInfo]::new($f).Length
            }
            return $sum
        }
        if ([System.IO.File]::Exists($Path)) { return [System.IO.FileInfo]::new($Path).Length }
        return 0
    } catch { return 0 }
}

# 行号 → 删除/保留决策(纯函数,不写盘不读确认)。返回:
# { Root, MinLines, Keep, Keep@(), Candidates@() }
# Keep 项: { DirName, Name, Sid8, Lines, Mod }
# Candidates 项: { Type='J'|'D'|'O'|'W', Path, Display, Reason, Size }
# W = 整目录删除(无 ≥MinLines 会话);其余三类不会与 W 重叠(组内 W 时不再拆列)
function Get-CctCleanupCandidates {
    param(
        [string]$Root,
        [int]$MinLines = 20,
        [int]$Keep = 1,
        [string[]]$ExcludePatterns = @()
    )

    $kept = [System.Collections.Generic.List[object]]::new()
    $cands = [System.Collections.Generic.List[object]]::new()

    foreach ($dir in [System.IO.Directory]::GetDirectories($Root)) {
        # 排除模式(worktrees / Temp 等:路径含模式即跳过,与数据层口径一致)
        $skipDir = $false
        foreach ($pat in $ExcludePatterns) { if ($dir -like "*$pat*") { $skipDir = $true; break } }
        if ($skipDir) { continue }

        $files = [System.Collections.Generic.List[object]]::new()
        foreach ($f in [System.IO.Directory]::EnumerateFiles($dir, '*.jsonl')) {
            $name = [System.IO.Path]::GetFileName($f)
            if ($name -like 'agent-*' -or $name -eq 'journal.jsonl') { continue }
            $lines = Get-CctLineCount -Path $f
            if ($lines -lt 0) { continue }   # 读失败:不判不删,保守跳过
            $files.Add([pscustomobject]@{
                Path = $f; Name = $name; Sid = ($name -replace '\.jsonl$', '')
                Length = [System.IO.FileInfo]::new($f).Length
                Lines = $lines; Mod = [System.IO.File]::GetLastWriteTimeUtc($f)
            })
        }
        if ($files.Count -eq 0) { continue }
        $dirName = [System.IO.Path]::GetFileName($dir)

        # 行数 ≥ 阈值的会话:最新在前
        $eligible = @($files | Where-Object Lines -ge $MinLines | Sort-Object @{Expression = 'Mod'; Descending = $true}, @{Expression = 'Lines'; Descending = $true})

        if ($eligible.Count -eq 0) {
            # 整目录删除:目录内所有会话都 < 阈值(含 memory、附属目录、孤立残留,整体删)
            $cands.Add([pscustomobject]@{
                Type = 'W'; Path = $dir; Display = $dirName
                Reason = "目录内 $($files.Count) 个会话均小于 $MinLines 行,整目录删除(含 memory)"
                Size = Get-CctCleanupSize -Path $dir
            })
            continue
        }

        $nKeep = [Math]::Min($Keep, $eligible.Count)
        $keepItems = @($eligible[0..($nKeep - 1)])
        $keepSids = [System.Collections.Generic.HashSet[string]]::new()
        $allSids = [System.Collections.Generic.HashSet[string]]::new()
        foreach ($f in $files) { [void]$allSids.Add($f.Sid) }
        $keptDesc = [System.Collections.Generic.List[string]]::new()
        foreach ($k in $keepItems) {
            [void]$keepSids.Add($k.Sid)
            [void]$allSids.Add($k.Sid)
            $kept.Add([pscustomobject]@{
                DirName = $dirName; Name = $k.Name; Sid8 = $k.Sid.Substring(0, [Math]::Min(8, $k.Sid.Length))
                Lines = $k.Lines; Mod = $k.Mod
            })
            $keptDesc.Add("$($k.Sid.Substring(0, [Math]::Min(8, $k.Sid.Length)))($($k.Lines)行)")
        }
        $keptSummary = if ($keepItems.Count -eq 1) { "同目录保留 $($keptDesc[0])" } else { "本目录保留 $($keepItems.Count) 个:$($keptDesc -join '、')" }

        # 未保留的会话 jsonl → J(连同其同名附属目录 → D)
        foreach ($f in $files) {
            if ($keepSids.Contains($f.Sid)) { continue }
            $cands.Add([pscustomobject]@{
                Type = 'J'; Path = $f.Path; Display = "$dirName\$($f.Name)"
                Reason = "非保留会话($keptSummary)"; Size = $f.Length
            })
        }

        # 目录下子目录:保留会话的附属目录不动;被删会话的附属目录 → D;无对应 jsonl → O(孤立)
        foreach ($sub in [System.IO.Directory]::GetDirectories($dir)) {
            $leaf = [System.IO.Path]::GetFileName($sub)
            if ($leaf -eq 'memory') { continue }          # 目录还有保留会话 → memory 保留
            if ($keepSids.Contains($leaf)) { continue }   # 保留会话的附属目录
            if ($allSids.Contains($leaf)) {
                $cands.Add([pscustomobject]@{
                    Type = 'D'; Path = $sub; Display = "$dirName\$leaf"
                    Reason = "会话 $($leaf.Substring(0, [Math]::Min(8, $leaf.Length))) 的附属目录(subagents/tool-results/workflows)"
                    Size = Get-CctCleanupSize -Path $sub
                })
            } else {
                $cands.Add([pscustomobject]@{
                    Type = 'O'; Path = $sub; Display = "$dirName\$leaf"
                    Reason = '孤立目录(无对应会话 jsonl)'; Size = Get-CctCleanupSize -Path $sub
                })
            }
        }
    }

    return [pscustomobject]@{
        Root = $Root; MinLines = $MinLines; KeepCount = $Keep
        Keep = @($kept); Candidates = @($cands)
    }
}

# 预览文本(行数组,纯函数供测试直接断言):
# 第一段 = 过滤规则;第二段 = 保留明细;第三段 = 待删清单(类型/路径/理由/大小)+ 合计
function Get-CctClearText {
    param([pscustomobject]$Result)

    $out = [System.Collections.Generic.List[string]]::new()
    $out.Add('清理规则(只处理 ~/.claude/projects 下的会话历史目录):')
    $out.Add(" 1. 「对话次数」按会话 .jsonl 的行数近似,行数 ≥ $($Result.MinLines) 才算有效会话。")
    $out.Add(" 2. 每个目录保留「行数 ≥ $($Result.MinLines)」里最新 $($Result.KeepCount) 个(按修改时间降序);")
    $out.Add(' 其余会话 jsonl、被删会话的附属目录(subagents/tool-results/workflows)、孤立目录列入删除。')
    $out.Add(' 3. 目录内所有会话都 < 阈值 → 整目录删除(含 memory)。')
    if ($Result.Keep.Count -gt 0) {
        $out.Add('')
        $out.Add("将保留 $($Result.Keep.Count) 个会话(每目录 $($Result.KeepCount) 个最新会话,其余清理):")
        foreach ($k in $Result.Keep) {
            $out.Add(" 保留 $($k.DirName)\$($k.Name)($($k.Lines) 行,$($k.Mod.ToLocalTime().ToString('MM-dd HH:mm')))")
        }
    }

    $cands = @($Result.Candidates)
    $out.Add('')
    if ($cands.Count -eq 0) {
        $out.Add('没有需要清理的会话(每个目录都已只剩最新会话,或没有足够多的历史)。')
        return @($out)
    }
    $totalBytes = 0L
    foreach ($c in $cands) { $totalBytes += $c.Size }
    $out.Add("待删除 $($cands.Count) 项(约 $(Format-CctBytes -Bytes $totalBytes)):")
    foreach ($c in $cands) {
        $out.Add(" [$($c.Type)] $($c.Display)($(Format-CctBytes -Bytes $c.Size))——$($c.Reason)")
    }
    return @($out)
}

# 删除待删清单(真实删除,破坏性)。返回 { Deleted, Bytes, Failed }
function Remove-CctCleanupItems {
    param([object[]]$Candidates)
    $deleted = 0; $bytes = 0L; $failed = 0
    foreach ($c in $Candidates) {
        if (-not (Test-Path -LiteralPath $c.Path)) { $deleted++; $bytes += $c.Size; continue }   # 已不存在视为已删
        try {
            Remove-Item -LiteralPath $c.Path -Recurse -Force -ErrorAction Stop
            $deleted++; $bytes += $c.Size
        } catch { $failed++ }
    }
    return [pscustomobject]@{ Deleted = $deleted; Bytes = $bytes; Failed = $failed }
}

# clear 专属参数解析(与共享 Split-CctCommandArgs 分离:-cc/-y/--keep/--min 是 clear 私有,
# 不塞进共享选项层,避免 cct list -cc 之类被静默接受)
function Parse-CctClearArgs {
    param([string[]]$Tokens)
    $cc = $false; $yes = $false; $dryRun = $false; $keep = $null; $min = $null
    $i = 0
    while ($i -lt $Tokens.Count) {
        $t = [string]$Tokens[$i]
        switch ($t.ToLowerInvariant()) {
            '-cc'       { $cc = $true; $i++; break }
            '-y'        { $yes = $true; $i++; break }
            '--yes'     { $yes = $true; $i++; break }
            '--dry-run' { $dryRun = $true; $i++; break }
            '--keep'    {
                if ($i + 1 -ge $Tokens.Count) { throw 'cct clear --keep 需要一个数字值(用 cct clear -h 查看帮助)' }
                $keep = [int]$Tokens[$i + 1]; $i += 2; break
            }
            '--min'     {
                if ($i + 1 -ge $Tokens.Count) { throw 'cct clear --min 需要一个数字值(用 cct clear -h 查看帮助)' }
                $min = [int]$Tokens[$i + 1]; $i += 2; break
            }
            default     { throw "未知选项: $t(用 cct clear -h 查看帮助)" }
        }
    }
    if ($null -ne $keep -and $keep -lt 1) { throw 'cct clear --keep 必须 ≥ 1' }
    if ($null -ne $min -and $min -lt 1) { throw 'cct clear --min 必须 ≥ 1' }
    return [pscustomobject]@{ Cc = $cc; Yes = $yes; DryRun = $dryRun; Keep = $keep; Min = $min }
}

# cct clear 主流程。Tokens 由外层路由传入;Root/ConfigPath 供测试注入。
# 分支:无 -cc → 清扫描缓存;有 -cc → 清理多余会话(先预览,确认后删)。
# 返回状态文本(无 -cc 分支)或 $null(-cc 分支,输出走 Write-Host)。
function Invoke-CctClear {
    param(
        [string[]]$Tokens = @(),
        [bool]$HasTty = $true,
        [string]$Root = (Join-Path $env:USERPROFILE '.claude\projects'),
        [string]$ConfigPath = (Join-Path $env:USERPROFILE '.cct\config.json')
    )
    $o = Parse-CctClearArgs -Tokens @($Tokens)

    if (-not $o.Cc) {
        # 清 cct 扫描缓存(安全操作):缓存文件 = config 同目录 cache.json;config.json 不动
        $cfgDir = Split-Path -Parent $ConfigPath
        $cachePath = Join-Path $cfgDir 'cache.json'
        if (-not (Test-Path -LiteralPath $cachePath)) {
            return '[ok] 没有 cct 扫描缓存,无需清理(缓存文件:' + (Join-Path $cfgDir 'cache.json') + ')'
        }
        $sz = Get-CctCleanupSize -Path $cachePath
        Remove-Item -LiteralPath $cachePath -Force
        return "[ok] 已清除 cct 扫描缓存($(Format-CctBytes -Bytes $sz)),下次扫描将全量重建缓存"
    }

    # -cc:清理多余会话(不动 cct 缓存)。删除不可逆 → 先打印规则与清单。
    $cfg = Get-CctConfig -Path $ConfigPath
    $result = Get-CctCleanupCandidates -Root $Root -MinLines ($o.Min ?? 20) -Keep ($o.Keep ?? 1) -ExcludePatterns $cfg.excludePathPatterns
    foreach ($l in Get-CctClearText -Result $result) { Write-Host $l }

    if ($result.Candidates.Count -eq 0) { return }

    if ($o.DryRun -or (-not $HasTty -and -not $o.Yes)) {
        if (-not $HasTty -and -not $o.Yes) { Write-Host '(非交互环境且未加 --yes:仅预览,未执行删除)' }
        return
    }
    if (-not $o.Yes) {
        Write-Host -NoNewline '确认删除以上全部待删项?[y/N] '
        $ans = Read-Host
        if (([string]$ans).Trim().ToLowerInvariant() -notin @('y', 'yes')) {
            Write-Host '已取消,未删除任何文件。'
            return
        }
    }
    $res = Remove-CctCleanupItems -Candidates $result.Candidates
    Write-Host "已删除 $($res.Deleted) 项,释放约 $(Format-CctBytes -Bytes $res.Bytes)。"
    if ($res.Failed -gt 0) { Write-Host "有 $($res.Failed) 项删除失败(请手动检查)。" }
}