Data.ps1

# 数据层:从 ~/.claude/projects 的 jsonl 提取会话记录
# 性能关键:行级处理走 ClaudeCodeTask.Core 内核 dll(PowerShell 逐行调用开销是 C# 的 24 倍,实测 17s → 0.7s)
# C# 部分只做字符串标记扫描;「真实用户消息」判定语义与设计一致:
# 非 isMeta、content 为字符串(非 / 开头)或含 text 块(排除 tool_result 回传)
# 第四轮修复(2026-09-01):
# - 去掉第 10 条用户消息早停——探针实测 27/27 样本标题行在文件尾部,早停导致
# LastTimestamp 停在早停点(b8c4b9b3 显示 08-25 实际活动到 08-31),排序与 resume 目标失真
# - 吸收 cc-switch 判定:<local-command-caveat> / <command-name> 是系统注入的伪用户消息,不计入 UserMsgs
# 第五轮修复(2026-09-01):
# - 类名版本化 CctScanner → CctScannerV2:Add-Type 类型缓存在进程 AppDomain,
# Import-Module -Force 不会重编译内联 C#。第三轮旧签名(双参 ScanFile)缓存在常驻
# 终端的进程里,守卫看到旧类型存在就跳过编译 → 新代码调单参重载抛异常被 catch 吃掉
# → 所有会话变空记录 → 「没有可显示的任务」。版本化类名让新旧类型共存,常驻终端立即可用。
# 第九轮修复(2026-09-01):
# - 类名版本化 CctScannerV2 → CctScannerV3:签名变更(返回 firstCwd+lastCwd 两列 cwd)。
# 根因:jsonl 物理存储目录 = 会话启动目录(cwd-first)编码目录;旧版只记 lastCwd(末现 cwd),
# 按它分组会造出「幻影文件夹」——指向无编码目录的路径(公益站签到/js/claude-code-zh-cn),
# 选中后 claude -c 找不到会话(第九轮反馈 1+2)。V3 同时记首现(存储目录,分组键)
# 与末现(resume 目标目录,决策 9 启动先 Set-Location)两列。
# 第十一轮优化(2026-09-01):
# - 类名版本化 CctScannerV3 → CctScannerV4:新增 ScanAll 并行全扫。剖析实测瓶颈在 C# 串行
# 逐文件扫描(298MB/133 文件占全程 ~87%,~2.3s)。文件间无共享状态 → Parallel.For 并发读盘
# 扫描,单文件异常返回空记录不中断。V3 只留单文件 ScanFile 给 Read-CctSessionFile 复用。
# 第十三轮优化(2026-09-01):
# - 融合增量缓存:会话文件 ≥ 拐点($script:CctCacheMinFiles=20,实测 N≤20 全扫平缓 ~150ms,
# N≥20 线性涨至 ~660ms)且缓存存在时,用 mtime+size 判定未变文件直接复用上次精确扫描结果,
# 只重扫变化文件;文件 < 拐点走原全量方案且不读写缓存(少量会话时原本方案足够快)。
# - 用户提议「行数近似 UserMsgs」经 130 文件样本验证不可行(比例 0.001~0.208、阈值误判 79-92%),
# 故缓存仍存精确 userMsgs(只是避免重复全量扫描),正确性无损。
# - 缓存存 ~/.cct/cache.json(与 config.json 同目录);读/写失败均静默回退全量。
# 第十九轮修复(2026-09-07):
# - 类名版本化 CctScannerV4 → CctScannerV5:输出 6→7 列,新增 ancestors(文件内蛇形
# session_id 指向的他者会话 id,';' 连接)。根因:claude -c/--resume 续接会话会在同一
# 编码目录生成携带祖先历史的新 jsonl(历史行 message 层带蛇形 session_id),旧扫描
# 无法识别血缘 → 祖先与后代各自列出(同题同名重复卡,用户反馈 bug 1);
# 碎片目录全部 <阈值时只剩 folder 卡,folder 副标题与进入后标题不一致(用户反馈 bug 2)。
# - 聚合层新增「续接分叉折叠」:同目录内后代达标时折叠祖先(对话链只留最新端点);
# 「目录升格」:纯碎片目录(无任何合格会话)把「有标题且 ≥1 真实消息」的最新碎片
# 升格为会话卡(豁免阈值,精确 --resume),folder 头被既有逻辑滤除。

# 文件数拐点:达到该值且缓存可用才走增量路径(测试可覆盖该 script 变量)
$script:CctCacheMinFiles = 20

# 标题类型枚举(读取层):jsonl 标题事件原文 → 内部枚举。custom = 用户手动命名、
# ai = AI 自动生成。分组比较逻辑用此枚举;展示层另有中文值(见 Get-CctTasks 输出项)
$script:CctTitleKindMap = @{ 'custom' = 'userCustom'; 'ai' = 'aiGenerate' }

# C# 扫描器已独立到 ClaudeCodeTask.Core(预编译 dll,改动内核后跑该目录 build.ps1 重新编译)。
# 类名版本化约定保留:签名变更必须换名(V2→V3→V4→V5),Add-Type 类型缓存仍在进程 AppDomain。
if (-not ('CctScannerV5' -as [type])) {
    Add-Type -Path (Join-Path $PSScriptRoot 'lib\ClaudeCodeTask.Core.dll')
}

function Get-JsonProp {
    param([System.Text.Json.JsonElement]$Element, [string]$Name)
    if ($Element.ValueKind -ne [System.Text.Json.JsonValueKind]::Object) { return $null }
    $v = [System.Text.Json.JsonElement]::new()
    if ($Element.TryGetProperty($Name, [ref]$v)) { return $v }
    return $null
}

# C# 扫描结果 string[] → 会话记录对象(Read-CctSessionFile 与 Get-CctTasks 并行路径共用)
function New-CctSessionRecord {
    param([string]$Path, [string[]]$Raw)

    $sessionId = [System.IO.Path]::GetFileNameWithoutExtension($Path)
    $lastTs = $null
    if ($Raw[2]) {
        $parsed = [datetime]::MinValue
        if ([datetime]::TryParse($Raw[2], [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal, [ref]$parsed)) {
            $lastTs = $parsed
        }
    }
    $userMsgs = [int]$Raw[5]

    # 第十九轮:第 7 列 ancestors(';' 分隔的祖先会话 id;空串 → 空数组)
    $ancestors = if ($Raw.Length -gt 6 -and $Raw[6]) { $Raw[6].Split(';') } else { @() }

    return [pscustomobject]@{
        SessionId      = $sessionId
        StartCwd       = if ($Raw[0]) { $Raw[0] } else { $null }   # 首现 cwd = 启动/存储目录
        Cwd            = if ($Raw[1]) { $Raw[1] } else { $null }   # 末现 cwd = resume 目标
        LastTimestamp  = $lastTs
        Title          = if ($Raw[3]) { $Raw[3] } else { $null }
        TitleType      = if ($Raw[4] -and $script:CctTitleKindMap.ContainsKey([string]$Raw[4])) { $script:CctTitleKindMap[[string]$Raw[4]] } else { $null }
        UserMsgs       = $userMsgs
        HasRealUserMsg = ($userMsgs -ge 1)
        Ancestors      = @($ancestors)
    }
}

# 读取单个会话文件(C# 快扫 + PowerShell 只做类型转换)
function Read-CctSessionFile {
    param([string]$Path)

    $sessionId = [System.IO.Path]::GetFileNameWithoutExtension($Path)
    $raw = $null
    try {
        $raw = [CctScannerV5]::ScanFile($Path)
    } catch {
        # 读取失败(文件被占用等):返回空记录
        return [pscustomobject]@{
            SessionId = $sessionId; StartCwd = $null; Cwd = $null; LastTimestamp = $null
            Title = $null; TitleType = $null; UserMsgs = 0; HasRealUserMsg = $false; Ancestors = @()
        }
    }

    return New-CctSessionRecord $Path $raw
}

# 读取会话扫描缓存(结构: {version:2, files:{<path>:{mt:<ticks>, sz:<bytes>, raw:<string[7]>}}})
# 第十九轮 version 1→2(raw 6→7 列,旧缓存无 ancestors 列);损坏/版本不符/不存在 →
# 返回 $null(调用方回退全量扫描,旧缓存被新结构覆盖)
function Read-CctSessionCache {
    param([string]$Path)

    if (-not (Test-Path -LiteralPath $Path)) { return $null }
    try {
        $obj = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json
        if ($null -eq $obj -or $obj.version -ne 2 -or $null -eq $obj.files) { return $null }
        $filesMap = @{}
        foreach ($prop in $obj.files.PSObject.Properties) { $filesMap[$prop.Name] = $prop.Value }
        return @{ version = 2; files = $filesMap }
    } catch {
        return $null
    }
}

# 写会话扫描缓存(失败静默:缓存只是加速手段,下次重建即可)
function Write-CctSessionCache {
    param([string]$Path, [hashtable]$Cache)

    try {
        $dir = Split-Path -Parent $Path
        if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force $dir | Out-Null }
        $Cache | ConvertTo-Json -Depth 5 -Compress | Set-Content -LiteralPath $Path -Encoding utf8 -ErrorAction Stop
    } catch {
        # 缓存写失败不致命:下次无缓存走全量
    }
}

# 扫描 projects 目录并聚合成列表项(spec 3.3/3.5/3.6)
function Get-CctTasks {
    [CmdletBinding()]
    param(
        [string]$Root = (Join-Path $env:USERPROFILE '.claude\projects'),
        [int]$MinUserMsgs = 10,
        # 排除模式(默认值须与 Config.ps1 保持一致;ConfigBackup 不默认排除——用户决策 2026-08-31):
        # Temp 排除开发探针残留会话(发现 9/22 调研产物)
        [string[]]$ExcludePatterns = @('AppData\Local\Temp'),
        # 第十三轮:扫描缓存路径(文件数 ≥ 拐点时用于增量复用;测试可注入独立路径)
        [string]$CachePath = (Join-Path $env:USERPROFILE '.cct\cache.json'),
        # 第十八轮:查找/列出时是否含「文件夹」条目。内部始终用 folder 做分组排序;仅返回前按此过滤。
        # 默认 true(扫描器原始产物);交互式/list/find/run 等输出链路按 config.includeFolderFind 传值。
        [bool]$IncludeFolderFind = $true
    )

    # 1. 收集文件(排除 agent-/journal.jsonl/排除模式)
    $files = Get-ChildItem -LiteralPath $Root -Recurse -Filter *.jsonl -File -ErrorAction SilentlyContinue | Where-Object {
        $_.Name -ne 'journal.jsonl' -and $_.Name -notlike 'agent-*'
    }
    if ($ExcludePatterns) {
        $files = $files | Where-Object {
            $p = $_.FullName
            -not ($ExcludePatterns | Where-Object { $p -like "*$_*" })
        }
    }

    # 2. 逐文件提取(第十一轮:C# 并行全扫;第十三轮:文件数 ≥ 拐点且缓存可用时增量复用)
    $paths = [string[]]@($files | ForEach-Object { $_.FullName })
    $degree = [Math]::Min($paths.Length, [Math]::Max(1, [Environment]::ProcessorCount))

    $rawAll = $null
    $cache = Read-CctSessionCache -Path $CachePath
    if ($paths.Count -ge $script:CctCacheMinFiles -and $null -ne $cache) {
        # 增量:mtime+size 命中的文件复用缓存精确结果,只重扫变化/新增的文件
        $fileByPath = @{}
        foreach ($f in $files) { $fileByPath[$f.FullName] = $f }
        $pathIndex = @{}
        for ($i = 0; $i -lt $paths.Length; $i++) { $pathIndex[$paths[$i]] = $i }
        $rawAll = [string[][]]::new($paths.Length)
        $dirty = [System.Collections.Generic.List[string]]::new()
        foreach ($f in $files) {
            $p = $f.FullName
            $entry = $cache.files[$p]
            if ($null -ne $entry -and $entry.mt -eq $f.LastWriteTimeUtc.Ticks -and $entry.sz -eq $f.Length) {
                $rawAll[$pathIndex[$p]] = [string[]]$entry.raw
            } else {
                $dirty.Add($p)
            }
        }
        if ($dirty.Count -gt 0) {
            $dirtyRaw = [CctScannerV5]::ScanAll([string[]]@($dirty), $degree)
            for ($d = 0; $d -lt $dirty.Count; $d++) {
                $p = $dirty[$d]
                $rawAll[$pathIndex[$p]] = $dirtyRaw[$d]
                $cf = $fileByPath[$p]
                $cache.files[$p] = [pscustomobject]@{ mt = $cf.LastWriteTimeUtc.Ticks; sz = $cf.Length; raw = $dirtyRaw[$d] }
            }
        }
        # 清理缓存中已不存在的文件(目录被删/会话清理)
        foreach ($key in @($cache.files.Keys)) {
            if (-not $pathIndex.ContainsKey($key)) { $cache.files.Remove($key) }
        }
        Write-CctSessionCache -Path $CachePath -Cache $cache
    } else {
        # 全量(无缓存或文件 < 拐点);≥ 拐点且首次无缓存时写缓存供下次增量
        $rawAll = [CctScannerV5]::ScanAll($paths, $degree)
        if ($paths.Count -ge $script:CctCacheMinFiles) {
            $newCache = @{ version = 2; files = @{} }
            for ($i = 0; $i -lt $paths.Length; $i++) {
                $cf = $files[$i]
                $newCache.files[$paths[$i]] = [pscustomobject]@{ mt = $cf.LastWriteTimeUtc.Ticks; sz = $cf.Length; raw = $rawAll[$i] }
            }
            Write-CctSessionCache -Path $CachePath -Cache $newCache
        }
    }

    $sessions = [System.Collections.Generic.List[object]]::new()
    for ($i = 0; $i -lt $paths.Length; $i++) {
        $r = New-CctSessionRecord $paths[$i] $rawAll[$i]
        if ($r.Cwd -and $r.LastTimestamp) { $sessions.Add($r) }
    }

    # 3. 过滤失效目录(worktree 残留 + 已删除)与排除模式的 cwd(Temp 探针残留会话的 cwd 指向 Temp)
    # 第九轮:StartCwd(存储目录,分组键)与 Cwd(resume 目标)都须存在且过排除模式
    # 第十一轮:Test-Path → Directory.Exists(纯 .NET 目录存在性判定,免 PowerShell 提供程序开销)
    $valid = @($sessions | Where-Object {
        $startCwd = $_.StartCwd
        $cwd = $_.Cwd
        $startCwd -and $cwd -and
        $startCwd -notmatch '\.claude[\\/]worktrees' -and [System.IO.Directory]::Exists($startCwd) -and
        -not ($ExcludePatterns | Where-Object { $startCwd -like "*$($_)*" }) -and
        $cwd -notmatch '\.claude[\\/]worktrees' -and [System.IO.Directory]::Exists($cwd) -and
        -not ($ExcludePatterns | Where-Object { $cwd -like "*$($_)*" })
    })

    # 4. 按 StartCwd 分组(第九轮:jsonl 物理存储于启动目录编码目录,按首现 cwd 分组,
    # 文件夹编码目录必有 jsonl → 幻影 Folder 天然消失;Session 项 Path 仍用末现 cwd)
    $items = [System.Collections.Generic.List[object]]::new()
    foreach ($g in ($valid | Group-Object StartCwd)) {
        $folderPath = $g.Name
        $folderName = Split-Path -Leaf $folderPath
        $byTime = @($g.Group | Sort-Object LastTimestamp -Descending)
        $latest = $byTime[0]

        # 会话项判定(spec 3.5)
        $manualGroups = @($g.Group | Where-Object TitleType -eq 'userCustom' | Group-Object Title)
        $sessionItems = [System.Collections.Generic.List[object]]::new()
        foreach ($mg in $manualGroups) {
            # 决策 34 修订(2026-08-27):手动命名同样受 ≥MinUserMsgs 阈值约束——
            # <10 条的会话哪怕手动命名意义也不大。组内全部 <阈值 → 整组不保留。
            # 同名组内达标的会话各自列为独立条目(≥10 的多条同名不再只留最新,
            # 否则旧同名会话被静默隐藏、cct 进不去);界面靠最近活动时间区分同名条目
            $qualified = @($mg.Group | Where-Object { $_.HasRealUserMsg -and $_.UserMsgs -ge $MinUserMsgs } | Sort-Object LastTimestamp -Descending)
            if ($qualified.Count -eq 0) { continue }   # 整组不保留(不再防御回退到 <阈值 文件)

            # 第十九轮分叉折叠:同组内「祖先被本组合格后代声明」→ 祖先不单独列出。
            # 判据:后代 jsonl 历史行携带蛇形 session_id 指向祖先(扫描器 V5 ancestors 列),
            # 该字段只在续接分叉复制祖先历史时出现。只折叠同目录的祖先(血缘是目录内的对话链)。
            $qualifiedIds = [System.Collections.Generic.HashSet[string]]::new()
            foreach ($q in $qualified) { [void]$qualifiedIds.Add($q.SessionId) }
            $supersededIds = [System.Collections.Generic.HashSet[string]]::new()
            foreach ($q in $qualified) {
                foreach ($a in $q.Ancestors) {
                    if ($qualifiedIds.Contains($a) -and $a -ne $q.SessionId) { [void]$supersededIds.Add($a) }
                }
            }
            foreach ($q in $qualified) {
                if ($supersededIds.Contains($q.SessionId)) { continue }   # 被合格后代折叠,对话链只留最新端点
                $sessionItems.Add([pscustomobject]@{
                    Kind = 'Session'; Path = $q.Cwd; Name = $mg.Name   # Path=末现 cwd(resume 目标)
                    Subtitle = $null
                    LastActive = $q.LastTimestamp.ToLocalTime()
                    SessionId = $q.SessionId
                    GroupKey = $folderPath; TitleType = '自定义命名'   # GroupKey=StartCwd(挂到存储目录文件夹下)
                })
            }
        }
        if ($manualGroups.Count -eq 0) {
            # 保底自动项:目录无任何手动命名项(决策 36 原语义不变——手动项被阈值过滤不触发自动项顶替),
            # ≥MinUserMsgs 的 ai-title 里最新一个(决策 35/37;口径与决策 32 一致:≥)
            $aiBig = @($g.Group | Where-Object { $_.TitleType -eq 'aiGenerate' -and $_.UserMsgs -ge $MinUserMsgs -and $_.HasRealUserMsg } | Sort-Object LastTimestamp -Descending)
            if ($aiBig.Count -gt 0) {
                $sessionItems.Add([pscustomobject]@{
                    Kind = 'Session'; Path = $aiBig[0].Cwd; Name = $aiBig[0].Title   # Path=末现 cwd(resume 目标)
                    Subtitle = $null
                    LastActive = $aiBig[0].LastTimestamp.ToLocalTime()
                    SessionId = $aiBig[0].SessionId
                    GroupKey = $folderPath; TitleType = '自动生成'
                })
            }
        }

        # 第十九轮目录升格:组内无任何会话项(手动组全 <阈值 或无手动项且无达标 ai 项)时,
        # 把「有标题且 ≥1 条真实消息」的最新碎片升格为会话卡(豁免阈值)——碎片目录此前只剩
        # folder 卡,副标题(标题)与进入后 `claude -c` 恢复会话的标题不一致(用户反馈 bug 2);
        # 升格后卡片标题 = 进入后标题,且用精确 --resume 而非 -c 猜测最新。folder 头被既有
        # includeFolderFind 逻辑滤除(同组已有会话项)。
        if ($sessionItems.Count -eq 0) {
            $promote = @($g.Group | Where-Object { $_.Title -and $_.HasRealUserMsg } | Sort-Object LastTimestamp -Descending)
            if ($promote.Count -gt 0) {
                $p = $promote[0]
                $sessionItems.Add([pscustomobject]@{
                    Kind = 'Session'; Path = $p.Cwd; Name = $p.Title
                    Subtitle = $null
                    LastActive = $p.LastTimestamp.ToLocalTime()
                    SessionId = $p.SessionId
                    GroupKey = $folderPath; TitleType = if ($p.TitleType -eq 'userCustom') { '自定义命名' } else { '自动生成' }
                })
            }
        }

        # 文件夹项(副标题 = 最近会话标题,无标题留空,决策 30/38)
        $folderItem = [pscustomobject]@{
            Kind = 'Folder'; Path = $folderPath; Name = $folderName
            Subtitle = $latest.Title
            LastActive = $latest.LastTimestamp.ToLocalTime()
            SessionId = $null; GroupKey = $folderPath; TitleType = $null
        }
        $items.Add($folderItem)
        foreach ($s in ($sessionItems | Sort-Object LastActive -Descending)) { $items.Add($s) }
    }

    # 5. 文件夹间按最近活动降序,会话项紧跟所属文件夹(两步法:先排序文件夹,再挂会话项)
    $result = [System.Collections.Generic.List[object]]::new()
    foreach ($f in ($items | Where-Object Kind -eq 'Folder' | Sort-Object LastActive -Descending)) {
        $result.Add($f)
        foreach ($s in ($items | Where-Object { $_.Kind -eq 'Session' -and $_.GroupKey -eq $f.GroupKey } | Sort-Object LastActive -Descending)) {
            $result.Add($s)
        }
    }
    # 第十八轮:includeFolderFind=0 时去掉 folder 行;session 顺序不变(仍按目录分组、组内按最近活动降序)
    if (-not $IncludeFolderFind) {
        return @($result | Where-Object { $_.Kind -eq 'Session' })
    }
    # includeFolderFind=1 时 folder 头仅在「组内无任何 Session 项」的纯目录保留——
    # 同目录已有可 resume 的会话时 folder 头是冗余入口(session 优先),滤除之;顺序不受影响
    $sessionKeys = [System.Collections.Generic.HashSet[string]]::new()
    foreach ($s in $result) {
        if ($s.Kind -eq 'Session' -and $s.GroupKey) { [void]$sessionKeys.Add([string]$s.GroupKey) }
    }
    return @($result | Where-Object { -not ($_.Kind -eq 'Folder' -and $sessionKeys.Contains([string]$_.GroupKey)) })
}

# 搜索过滤(决策 16:文件夹名+会话标题+全路径并集,不区分大小写子串;会话命中带出父文件夹)
function Filter-CctTasks {
    param([pscustomobject[]]$Tasks, [string]$Query)
    if ([string]::IsNullOrWhiteSpace($Query)) { return $Tasks }
    $q = $Query.ToLowerInvariant()

    $hits = @($Tasks | Where-Object {
        $t = $_
        ($t.Name -and $t.Name.ToLowerInvariant().Contains($q)) -or
        ($t.Subtitle -and $t.Subtitle.ToLowerInvariant().Contains($q)) -or
        ($t.Path -and $t.Path.ToLowerInvariant().Contains($q))
    })
    # 会话项命中 → 父文件夹(同 GroupKey 的 Folder 项)一起显示
    $groupKeys = @($hits | Where-Object Kind -eq 'Session' | ForEach-Object GroupKey)
    $withContext = @($hits) + @($Tasks | Where-Object { $_.Kind -eq 'Folder' -and $groupKeys -contains $_.GroupKey })
    # 保持原顺序去重;Session 同名且同 cwd 可有多条(同名会话各自列出),去重键并入 SessionId
    $seen = [System.Collections.Generic.HashSet[string]]::new()
    $result = foreach ($it in $withContext) {
        $key = if ($it.Kind -eq 'Session') { "Session|$($it.SessionId)" } else { "$($it.Kind)|$($it.Path)|$($it.Name)" }
        if ($seen.Add($key)) { $it }
    }
    return @($result)
}