@@ -1797,6 +1797,210 @@ func FindOpenClawSourceFile(agentsDir, rawID string) string {
17971797 return ""
17981798}
17991799
1800+ // DiscoverQClawSessions finds all JSONL session files under the
1801+ // QClaw agents directory. The directory structure is:
1802+ // <agentsDir>/<agentId>/sessions/<sessionId>.jsonl
1803+ //
1804+ // When both active (.jsonl) and archived (.jsonl.deleted.*,
1805+ // .jsonl.full.bak, .jsonl.reset.*) files exist for the same
1806+ // logical session ID, only one file is returned per session:
1807+ // the active .jsonl file is preferred; if absent, the newest
1808+ // archived file (by filename, which embeds a timestamp, or by
1809+ // file mtime as a fallback) is chosen.
1810+ func DiscoverQClawSessions (agentsDir string ) []DiscoveredFile {
1811+ if agentsDir == "" {
1812+ return nil
1813+ }
1814+
1815+ // Each agent has its own subdirectory.
1816+ agentEntries , err := os .ReadDir (agentsDir )
1817+ if err != nil {
1818+ return nil
1819+ }
1820+
1821+ var files []DiscoveredFile
1822+ for _ , agentEntry := range agentEntries {
1823+ if ! isDirOrSymlink (agentEntry , agentsDir ) {
1824+ continue
1825+ }
1826+ if ! IsValidSessionID (agentEntry .Name ()) {
1827+ continue
1828+ }
1829+
1830+ sessionsDir := filepath .Join (
1831+ agentsDir , agentEntry .Name (), "sessions" ,
1832+ )
1833+ entries , err := os .ReadDir (sessionsDir )
1834+ if err != nil {
1835+ continue
1836+ }
1837+
1838+ // Deduplicate by logical session ID within each
1839+ // agent's sessions directory.
1840+ best := make (map [string ]os.DirEntry ) // sessionID -> best entry
1841+ for _ , entry := range entries {
1842+ if entry .IsDir () {
1843+ continue
1844+ }
1845+ name := entry .Name ()
1846+ if ! IsQClawSessionFile (name ) {
1847+ continue
1848+ }
1849+ sid := QClawSessionID (name )
1850+ prev , exists := best [sid ]
1851+ if ! exists {
1852+ best [sid ] = entry
1853+ continue
1854+ }
1855+ best [sid ] = bestQClawEntry (prev , entry )
1856+ }
1857+
1858+ for _ , entry := range best {
1859+ files = append (files , DiscoveredFile {
1860+ Path : filepath .Join (
1861+ sessionsDir , entry .Name (),
1862+ ),
1863+ Agent : AgentQClaw ,
1864+ })
1865+ }
1866+ }
1867+
1868+ sort .Slice (files , func (i , j int ) bool {
1869+ return files [i ].Path < files [j ].Path
1870+ })
1871+ return files
1872+ }
1873+
1874+ // bestQClawEntry returns the preferred entry when two files
1875+ // share the same logical session ID. Active .jsonl files always
1876+ // win. Among archived files, the one with the newest embedded
1877+ // timestamp wins; when no timestamp is parseable, mtime is used.
1878+ func bestQClawEntry (a , b os.DirEntry ) os.DirEntry {
1879+ aActive := strings .HasSuffix (a .Name (), ".jsonl" )
1880+ bActive := strings .HasSuffix (b .Name (), ".jsonl" )
1881+ if aActive && ! bActive {
1882+ return a
1883+ }
1884+ if bActive && ! aActive {
1885+ return b
1886+ }
1887+ aTime := qClawArchiveTime (a )
1888+ bTime := qClawArchiveTime (b )
1889+ if ! aTime .IsZero () && ! bTime .IsZero () {
1890+ if bTime .After (aTime ) {
1891+ return b
1892+ }
1893+ return a
1894+ }
1895+ if ! aTime .IsZero () {
1896+ return a
1897+ }
1898+ if ! bTime .IsZero () {
1899+ return b
1900+ }
1901+ ai , errA := a .Info ()
1902+ bi , errB := b .Info ()
1903+ if errA == nil && errB == nil &&
1904+ bi .ModTime ().After (ai .ModTime ()) {
1905+ return b
1906+ }
1907+ return a
1908+ }
1909+
1910+ // qClawArchiveTime extracts the timestamp embedded in an
1911+ // QClaw archive filename suffix (e.g. ".deleted.2026-02-19T08-59-24.951Z").
1912+ func qClawArchiveTime (e os.DirEntry ) time.Time {
1913+ name := e .Name ()
1914+ idx := strings .Index (name , ".jsonl." )
1915+ if idx <= 0 {
1916+ return time.Time {}
1917+ }
1918+ suffix := name [idx + len (".jsonl." ):]
1919+ // suffix is e.g. "deleted.2026-02-19T08-59-24.951Z" or "full.bak"
1920+ _ , tsStr , ok := strings .Cut (suffix , "." )
1921+ if ! ok {
1922+ return time.Time {}
1923+ }
1924+ // Convert dash-separated time back to colons: 08-59-24 → 08:59:24
1925+ if tIdx := strings .IndexByte (tsStr , 'T' ); tIdx >= 0 {
1926+ datePart := tsStr [:tIdx + 1 ]
1927+ timePart := tsStr [tIdx + 1 :]
1928+ // Only replace first two dashes in time portion (hh-mm-ss)
1929+ timePart = strings .Replace (timePart , "-" , ":" , 1 )
1930+ timePart = strings .Replace (timePart , "-" , ":" , 1 )
1931+ tsStr = datePart + timePart
1932+ }
1933+ t , err := time .Parse ("2006-01-02T15:04:05.000Z" , tsStr )
1934+ if err != nil {
1935+ t , err = time .Parse ("2006-01-02T15:04:05Z" , tsStr )
1936+ }
1937+ if err != nil {
1938+ return time.Time {}
1939+ }
1940+ return t
1941+ }
1942+
1943+ // FindQClawSourceFile locates a QClaw session file by its
1944+ // raw ID (without the "qclaw:" prefix). The raw ID has the
1945+ // format "<agentId>:<sessionId>", which directly maps to the
1946+ // file at <agentsDir>/<agentId>/sessions/<sessionId>.jsonl.
1947+ //
1948+ // If the active .jsonl file does not exist (archive-only session),
1949+ // the sessions directory is scanned for any archived file whose
1950+ // logical session ID matches. When multiple archived files match,
1951+ // the best candidate (newest by filename timestamp) is returned.
1952+ func FindQClawSourceFile (agentsDir , rawID string ) string {
1953+ if agentsDir == "" {
1954+ return ""
1955+ }
1956+
1957+ // Split "agentId:sessionId" into its two parts.
1958+ agentID , sessionID , ok := strings .Cut (rawID , ":" )
1959+ if ! ok || ! IsValidSessionID (agentID ) ||
1960+ ! IsValidSessionID (sessionID ) {
1961+ return ""
1962+ }
1963+
1964+ sessionsDir := filepath .Join (
1965+ agentsDir , agentID , "sessions" ,
1966+ )
1967+
1968+ // Fast path: the active .jsonl file exists.
1969+ active := filepath .Join (sessionsDir , sessionID + ".jsonl" )
1970+ if _ , err := os .Stat (active ); err == nil {
1971+ return active
1972+ }
1973+
1974+ // Slow path: scan for archived files matching this session.
1975+ entries , err := os .ReadDir (sessionsDir )
1976+ if err != nil {
1977+ return ""
1978+ }
1979+
1980+ var best os.DirEntry
1981+ for _ , entry := range entries {
1982+ if entry .IsDir () {
1983+ continue
1984+ }
1985+ name := entry .Name ()
1986+ if ! IsQClawSessionFile (name ) {
1987+ continue
1988+ }
1989+ if QClawSessionID (name ) != sessionID {
1990+ continue
1991+ }
1992+ if best == nil {
1993+ best = entry
1994+ continue
1995+ }
1996+ best = bestQClawEntry (best , entry )
1997+ }
1998+ if best != nil {
1999+ return filepath .Join (sessionsDir , best .Name ())
2000+ }
2001+ return ""
2002+ }
2003+
18002004// DiscoverIflowProjects finds all project directories under the
18012005// iFlow projects dir and returns their JSONL session files.
18022006// iFlow stores sessions in .iflow/projects/<project>/session-<uuid>.jsonl
0 commit comments