aisessions 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ import { homedir } from 'node:os'
2
+ import { existsSync, readdirSync } from 'node:fs'
3
+ import { join, basename } from 'node:path'
4
+ import { readLimited, parseJsonl, pathSize, humanSize, ignored, titleFromObjs, fileTitle, dateFromPath, projName, guessProj, fmtDate } from './utils.js'
5
+
6
+ const HOME = homedir()
7
+ const DATA_DIR = process.env.HERMES_DATA_DIR || join(HOME, '.hermes')
8
+
9
+ export const AGENT_ID = 'hermes'
10
+ export const AGENT_LABEL = 'Hermes'
11
+ export const AGENT_COLOR = '#999'
12
+
13
+ export function isInstalled() { return existsSync(DATA_DIR) }
14
+
15
+ export async function loadSessions() {
16
+ const items = []
17
+ const walk = dir => {
18
+ if (!existsSync(dir)) return
19
+ try {
20
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
21
+ const full = join(dir, e.name)
22
+ if (ignored(full)) continue
23
+ if (e.isDirectory()) walk(full)
24
+ else if (e.name.endsWith('.jsonl') || e.name.endsWith('.json')) {
25
+ const text = readLimited(full)
26
+ const objs = parseJsonl(text)
27
+ const [title, tsrc] = titleFromObjs(objs)
28
+ const pp = guessProj(text) || 'Unknown'
29
+ const date = dateFromPath(full)
30
+ const size = pathSize(full)
31
+ items.push({ agent: AGENT_ID, agentLabel: AGENT_LABEL, path: full, sessionId: basename(full, e.name.endsWith('.jsonl') ? '.jsonl' : '.json'), title: title || fileTitle(full), titleSource: tsrc || 'filename', project: projName(pp), projectPath: pp, date, dateLabel: fmtDate(date), size, sizeLabel: humanSize(size), filename: e.name, msgCount: objs.length })
32
+ }
33
+ }
34
+ } catch {}
35
+ }
36
+ walk(DATA_DIR)
37
+ return items.sort((a,b) => b.date - a.date)
38
+ }
@@ -0,0 +1,39 @@
1
+ import * as claude from './claude.js'
2
+ import * as codex from './codex.js'
3
+ import * as opencode from './opencode.js'
4
+ import * as amp from './amp.js'
5
+ import * as droid from './droid.js'
6
+ import * as codebuff from './codebuff.js'
7
+ import * as hermes from './hermes.js'
8
+ import * as pi from './pi.js'
9
+ import * as goose from './goose.js'
10
+ import * as kilo from './kilo.js'
11
+ import * as copilot from './copilot.js'
12
+ import * as gemini from './gemini.js'
13
+ import * as kimi from './kimi.js'
14
+ import * as qwen from './qwen.js'
15
+ import * as openclaw from './openclaw.js'
16
+
17
+ const ALL_AGENTS = [
18
+ claude, codex, opencode, amp, droid,
19
+ codebuff, hermes, pi, goose, kilo,
20
+ copilot, gemini, kimi, qwen, openclaw,
21
+ ]
22
+
23
+ export function getInstalledAgents() {
24
+ return ALL_AGENTS
25
+ .filter(a => { try { return a.isInstalled() } catch { return false } })
26
+ .map(a => ({ id: a.AGENT_ID, label: a.AGENT_LABEL, color: a.AGENT_COLOR }))
27
+ }
28
+
29
+ export async function getAllSessions() {
30
+ const results = await Promise.allSettled(
31
+ ALL_AGENTS.map(a => {
32
+ try { return a.isInstalled() ? a.loadSessions() : Promise.resolve([]) }
33
+ catch { return Promise.resolve([]) }
34
+ })
35
+ )
36
+ return results
37
+ .flatMap(r => r.status === 'fulfilled' ? r.value : [])
38
+ .sort((a,b) => b.date - a.date)
39
+ }
@@ -0,0 +1,38 @@
1
+ import { homedir } from 'node:os'
2
+ import { existsSync, readdirSync } from 'node:fs'
3
+ import { join, basename } from 'node:path'
4
+ import { readLimited, parseJsonl, pathSize, humanSize, ignored, titleFromObjs, fileTitle, dateFromPath, projName, guessProj, fmtDate } from './utils.js'
5
+
6
+ const HOME = homedir()
7
+ const DATA_DIR = process.env.KILO_DATA_DIR || join(HOME, '.kilo')
8
+
9
+ export const AGENT_ID = 'kilo'
10
+ export const AGENT_LABEL = 'Kilo'
11
+ export const AGENT_COLOR = '#bbb'
12
+
13
+ export function isInstalled() { return existsSync(DATA_DIR) }
14
+
15
+ export async function loadSessions() {
16
+ const items = []
17
+ const walk = dir => {
18
+ if (!existsSync(dir)) return
19
+ try {
20
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
21
+ const full = join(dir, e.name)
22
+ if (ignored(full)) continue
23
+ if (e.isDirectory()) walk(full)
24
+ else if (e.name.endsWith('.jsonl') || e.name.endsWith('.json')) {
25
+ const text = readLimited(full)
26
+ const objs = parseJsonl(text)
27
+ const [title, tsrc] = titleFromObjs(objs)
28
+ const pp = guessProj(text) || 'Unknown'
29
+ const date = dateFromPath(full)
30
+ const size = pathSize(full)
31
+ items.push({ agent: AGENT_ID, agentLabel: AGENT_LABEL, path: full, sessionId: basename(full, e.name.endsWith('.jsonl') ? '.jsonl' : '.json'), title: title || fileTitle(full), titleSource: tsrc || 'filename', project: projName(pp), projectPath: pp, date, dateLabel: fmtDate(date), size, sizeLabel: humanSize(size), filename: e.name, msgCount: objs.length })
32
+ }
33
+ }
34
+ } catch {}
35
+ }
36
+ walk(DATA_DIR)
37
+ return items.sort((a,b) => b.date - a.date)
38
+ }
@@ -0,0 +1,38 @@
1
+ import { homedir } from 'node:os'
2
+ import { existsSync, readdirSync } from 'node:fs'
3
+ import { join, basename } from 'node:path'
4
+ import { readLimited, parseJsonl, pathSize, humanSize, ignored, titleFromObjs, fileTitle, dateFromPath, projName, guessProj, fmtDate } from './utils.js'
5
+
6
+ const HOME = homedir()
7
+ const DATA_DIR = process.env.KIMI_DATA_DIR || join(HOME, '.kimi')
8
+
9
+ export const AGENT_ID = 'kimi'
10
+ export const AGENT_LABEL = 'Kimi'
11
+ export const AGENT_COLOR = '#ddd'
12
+
13
+ export function isInstalled() { return existsSync(DATA_DIR) }
14
+
15
+ export async function loadSessions() {
16
+ const items = []
17
+ const walk = dir => {
18
+ if (!existsSync(dir)) return
19
+ try {
20
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
21
+ const full = join(dir, e.name)
22
+ if (ignored(full)) continue
23
+ if (e.isDirectory()) walk(full)
24
+ else if (e.name.endsWith('.jsonl') || e.name.endsWith('.json')) {
25
+ const text = readLimited(full)
26
+ const objs = parseJsonl(text)
27
+ const [title, tsrc] = titleFromObjs(objs)
28
+ const pp = guessProj(text) || 'Unknown'
29
+ const date = dateFromPath(full)
30
+ const size = pathSize(full)
31
+ items.push({ agent: AGENT_ID, agentLabel: AGENT_LABEL, path: full, sessionId: basename(full, e.name.endsWith('.jsonl') ? '.jsonl' : '.json'), title: title || fileTitle(full), titleSource: tsrc || 'filename', project: projName(pp), projectPath: pp, date, dateLabel: fmtDate(date), size, sizeLabel: humanSize(size), filename: e.name, msgCount: objs.length })
32
+ }
33
+ }
34
+ } catch {}
35
+ }
36
+ walk(DATA_DIR)
37
+ return items.sort((a,b) => b.date - a.date)
38
+ }
@@ -0,0 +1,38 @@
1
+ import { homedir } from 'node:os'
2
+ import { existsSync, readdirSync } from 'node:fs'
3
+ import { join, basename } from 'node:path'
4
+ import { readLimited, parseJsonl, pathSize, humanSize, ignored, titleFromObjs, fileTitle, dateFromPath, projName, guessProj, fmtDate } from './utils.js'
5
+
6
+ const HOME = homedir()
7
+ const DATA_DIR = process.env.OPENCLAW_DATA_DIR || join(HOME, '.openclaw')
8
+
9
+ export const AGENT_ID = 'openclaw'
10
+ export const AGENT_LABEL = 'OpenClaw'
11
+ export const AGENT_COLOR = '#f0f0f0'
12
+
13
+ export function isInstalled() { return existsSync(DATA_DIR) }
14
+
15
+ export async function loadSessions() {
16
+ const items = []
17
+ const walk = dir => {
18
+ if (!existsSync(dir)) return
19
+ try {
20
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
21
+ const full = join(dir, e.name)
22
+ if (ignored(full)) continue
23
+ if (e.isDirectory()) walk(full)
24
+ else if (e.name.endsWith('.jsonl') || e.name.endsWith('.json')) {
25
+ const text = readLimited(full)
26
+ const objs = parseJsonl(text)
27
+ const [title, tsrc] = titleFromObjs(objs)
28
+ const pp = guessProj(text) || 'Unknown'
29
+ const date = dateFromPath(full)
30
+ const size = pathSize(full)
31
+ items.push({ agent: AGENT_ID, agentLabel: AGENT_LABEL, path: full, sessionId: basename(full, e.name.endsWith('.jsonl') ? '.jsonl' : '.json'), title: title || fileTitle(full), titleSource: tsrc || 'filename', project: projName(pp), projectPath: pp, date, dateLabel: fmtDate(date), size, sizeLabel: humanSize(size), filename: e.name, msgCount: objs.length })
32
+ }
33
+ }
34
+ } catch {}
35
+ }
36
+ walk(DATA_DIR)
37
+ return items.sort((a,b) => b.date - a.date)
38
+ }
@@ -0,0 +1,50 @@
1
+ import { homedir } from 'node:os'
2
+ import { existsSync, readdirSync } from 'node:fs'
3
+ import { join, basename } from 'node:path'
4
+ import {
5
+ readLimited, parseJsonl, pathSize, humanSize, ignored,
6
+ titleFromObjs, fileTitle, dateFromPath, projName, guessProj, fmtDate,
7
+ } from './utils.js'
8
+
9
+ const HOME = homedir()
10
+ const DATA_DIR = process.env.OPENCODE_DATA_DIR || join(HOME, '.opencode')
11
+ const SESS_DIRS = [join(DATA_DIR, 'sessions'), DATA_DIR]
12
+
13
+ export const AGENT_ID = 'opencode'
14
+ export const AGENT_LABEL = 'OpenCode'
15
+ export const AGENT_COLOR = '#b0b0b0'
16
+
17
+ export function isInstalled() { return existsSync(DATA_DIR) }
18
+
19
+ export async function loadSessions() {
20
+ const items = []
21
+ const walk = dir => {
22
+ if (!existsSync(dir)) return
23
+ try {
24
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
25
+ const full = join(dir, e.name)
26
+ if (ignored(full)) continue
27
+ if (e.isDirectory()) walk(full)
28
+ else if (e.name.endsWith('.jsonl') || e.name.endsWith('.json')) {
29
+ const text = readLimited(full)
30
+ const objs = parseJsonl(text)
31
+ const [title, tsrc] = titleFromObjs(objs)
32
+ const pp = guessProj(text) || 'Unknown'
33
+ const date = dateFromPath(full)
34
+ const size = pathSize(full)
35
+ items.push({
36
+ agent: AGENT_ID, agentLabel: AGENT_LABEL,
37
+ path: full, sessionId: basename(full, e.name.endsWith('.jsonl') ? '.jsonl' : '.json'),
38
+ title: title || fileTitle(full), titleSource: tsrc || 'filename',
39
+ project: projName(pp), projectPath: pp,
40
+ date, dateLabel: fmtDate(date),
41
+ size, sizeLabel: humanSize(size),
42
+ filename: e.name, msgCount: objs.length,
43
+ })
44
+ }
45
+ }
46
+ } catch {}
47
+ }
48
+ SESS_DIRS.forEach(walk)
49
+ return items.sort((a,b) => b.date - a.date)
50
+ }
@@ -0,0 +1,41 @@
1
+ import { homedir } from 'node:os'
2
+ import { existsSync, readdirSync } from 'node:fs'
3
+ import { join, basename } from 'node:path'
4
+ import { readLimited, parseJsonl, pathSize, humanSize, ignored, titleFromObjs, fileTitle, dateFromPath, projName, guessProj, fmtDate } from './utils.js'
5
+
6
+ const HOME = homedir()
7
+ const DATA_DIR = process.env.PI_AGENT_DATA_DIR || join(HOME, '.pi-agent') || join(HOME, '.pi')
8
+
9
+ export const AGENT_ID = 'pi'
10
+ export const AGENT_LABEL = 'Pi Agent'
11
+ export const AGENT_COLOR = '#aaa'
12
+
13
+ export function isInstalled() {
14
+ return existsSync(join(HOME, '.pi-agent')) || existsSync(join(HOME, '.pi'))
15
+ }
16
+
17
+ export async function loadSessions() {
18
+ const dirs = [process.env.PI_AGENT_DATA_DIR, join(HOME, '.pi-agent'), join(HOME, '.pi')].filter(Boolean)
19
+ const items = []
20
+ const walk = dir => {
21
+ if (!existsSync(dir)) return
22
+ try {
23
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
24
+ const full = join(dir, e.name)
25
+ if (ignored(full)) continue
26
+ if (e.isDirectory()) walk(full)
27
+ else if (e.name.endsWith('.jsonl') || e.name.endsWith('.json')) {
28
+ const text = readLimited(full)
29
+ const objs = parseJsonl(text)
30
+ const [title, tsrc] = titleFromObjs(objs)
31
+ const pp = guessProj(text) || 'Unknown'
32
+ const date = dateFromPath(full)
33
+ const size = pathSize(full)
34
+ items.push({ agent: AGENT_ID, agentLabel: AGENT_LABEL, path: full, sessionId: basename(full, e.name.endsWith('.jsonl') ? '.jsonl' : '.json'), title: title || fileTitle(full), titleSource: tsrc || 'filename', project: projName(pp), projectPath: pp, date, dateLabel: fmtDate(date), size, sizeLabel: humanSize(size), filename: e.name, msgCount: objs.length })
35
+ }
36
+ }
37
+ } catch {}
38
+ }
39
+ dirs.forEach(walk)
40
+ return items.sort((a,b) => b.date - a.date)
41
+ }
@@ -0,0 +1,38 @@
1
+ import { homedir } from 'node:os'
2
+ import { existsSync, readdirSync } from 'node:fs'
3
+ import { join, basename } from 'node:path'
4
+ import { readLimited, parseJsonl, pathSize, humanSize, ignored, titleFromObjs, fileTitle, dateFromPath, projName, guessProj, fmtDate } from './utils.js'
5
+
6
+ const HOME = homedir()
7
+ const DATA_DIR = process.env.QWEN_DATA_DIR || join(HOME, '.qwen')
8
+
9
+ export const AGENT_ID = 'qwen'
10
+ export const AGENT_LABEL = 'Qwen'
11
+ export const AGENT_COLOR = '#eee'
12
+
13
+ export function isInstalled() { return existsSync(DATA_DIR) }
14
+
15
+ export async function loadSessions() {
16
+ const items = []
17
+ const walk = dir => {
18
+ if (!existsSync(dir)) return
19
+ try {
20
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
21
+ const full = join(dir, e.name)
22
+ if (ignored(full)) continue
23
+ if (e.isDirectory()) walk(full)
24
+ else if (e.name.endsWith('.jsonl') || e.name.endsWith('.json')) {
25
+ const text = readLimited(full)
26
+ const objs = parseJsonl(text)
27
+ const [title, tsrc] = titleFromObjs(objs)
28
+ const pp = guessProj(text) || 'Unknown'
29
+ const date = dateFromPath(full)
30
+ const size = pathSize(full)
31
+ items.push({ agent: AGENT_ID, agentLabel: AGENT_LABEL, path: full, sessionId: basename(full, e.name.endsWith('.jsonl') ? '.jsonl' : '.json'), title: title || fileTitle(full), titleSource: tsrc || 'filename', project: projName(pp), projectPath: pp, date, dateLabel: fmtDate(date), size, sizeLabel: humanSize(size), filename: e.name, msgCount: objs.length })
32
+ }
33
+ }
34
+ } catch {}
35
+ }
36
+ walk(DATA_DIR)
37
+ return items.sort((a,b) => b.date - a.date)
38
+ }
@@ -0,0 +1,283 @@
1
+ import { statSync, readdirSync, openSync, readSync, closeSync } from 'node:fs'
2
+ import { join, basename, extname } from 'node:path'
3
+
4
+ export const SKIP_PARTS = new Set(['.tmp','plugins','.git','node_modules','memory'])
5
+ export const MAX_BYTES = 8_000_000
6
+
7
+ export const BAD_TITLES = new Set([
8
+ 'none','null','undefined','untitled','no title','new chat','auto',
9
+ 'exec_command','automation_update','response_item','reasoning',
10
+ 'function_call','tool_call','shell_command','apply_patch',
11
+ 'read_file','write_file','list_files','update_plan','plan_update',
12
+ 'turn_context','message','event','delta','session',
13
+ 'input_text','output_text','read_thread_terminal','read_thread',
14
+ 'terminal','thread_terminal','codex_turn_context','conversation_item',
15
+ 'assistant_message','user_message','system_message',
16
+ 'local_shell_call','local_shell_output','mcp_tool_call','mcp_tool_output',
17
+ ])
18
+
19
+ export const TITLE_KEYS = new Set(['title','conversation_title','chat_title','thread_title','session_title','summary'])
20
+ export const PROJECT_KEYS = new Set(['cwd','workdir','working_directory','current_working_directory','project','project_path','repository','repo','workspace','workspace_path'])
21
+
22
+ export const CLAUDE_SKIP_PREFIX = [
23
+ '<local-command','<command-name','<command-message','<command-args',
24
+ '<local-command-stdout','<system-reminder','<function_calls>','<user-prompt-submit-hook',
25
+ ]
26
+
27
+ // ── I/O ──────────────────────────────────────────────────────────────────────
28
+
29
+ export function readLimited(p, limit = MAX_BYTES) {
30
+ try {
31
+ const st = statSync(p)
32
+ if (!st.isFile()) return ''
33
+ const size = Math.min(st.size, limit)
34
+ const buf = Buffer.allocUnsafe(size)
35
+ const fd = openSync(p, 'r')
36
+ readSync(fd, buf, 0, size, 0)
37
+ closeSync(fd)
38
+ return buf.toString('utf8')
39
+ } catch { return '' }
40
+ }
41
+
42
+ export function pathSize(p) {
43
+ try {
44
+ const st = statSync(p)
45
+ if (st.isFile()) return st.size
46
+ let total = 0
47
+ const walk = dir => {
48
+ try {
49
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
50
+ if (SKIP_PARTS.has(e.name)) continue
51
+ const full = join(dir, e.name)
52
+ if (e.isDirectory()) walk(full)
53
+ else try { total += statSync(full).size } catch {}
54
+ }
55
+ } catch {}
56
+ }
57
+ walk(p)
58
+ return total
59
+ } catch { return 0 }
60
+ }
61
+
62
+ export function humanSize(n) {
63
+ for (const u of ['B','KB','MB','GB']) {
64
+ if (n < 1024) return `${n.toFixed(1)} ${u}`
65
+ n /= 1024
66
+ }
67
+ return `${n.toFixed(1)} TB`
68
+ }
69
+
70
+ // ── JSONL ─────────────────────────────────────────────────────────────────────
71
+
72
+ export function parseJsonl(text) {
73
+ const objs = []
74
+ text = (text || '').trim()
75
+ if (!text) return objs
76
+ try {
77
+ const d = JSON.parse(text)
78
+ if (d && typeof d === 'object' && !Array.isArray(d)) return [d]
79
+ if (Array.isArray(d)) return d.filter(x => x && typeof x === 'object')
80
+ } catch {}
81
+ for (const line of text.split('\n')) {
82
+ const l = line.trim()
83
+ if (!l) continue
84
+ try { const d = JSON.parse(l); if (d && typeof d === 'object') objs.push(d) } catch {}
85
+ }
86
+ return objs
87
+ }
88
+
89
+ // ── String helpers ────────────────────────────────────────────────────────────
90
+
91
+ export function cleanStr(v) {
92
+ if (typeof v !== 'string') return ''
93
+ return v.replace(/\\n/g,' ').replace(/\\t/g,' ').replace(/\n|\t/g,' ').replace(/\s+/g,' ').trim()
94
+ }
95
+
96
+ export function badTitle(v) {
97
+ v = cleanStr(v)
98
+ if (!v) return true
99
+ const lo = v.toLowerCase()
100
+ if (BAD_TITLES.has(lo)) return true
101
+ if (v.length < 4 || v.length > 220) return true
102
+ if (v[0] === '{' || v[0] === '[') return true
103
+ if (v.startsWith('/') && !v.includes(' ')) return true
104
+ if (/^[a-f0-9-]{20,}$/i.test(lo)) return true
105
+ if (/^rollout-\d{4}-/.test(lo)) return true
106
+ if (lo.endsWith('.json') || lo.endsWith('.jsonl')) return true
107
+ if (lo.startsWith('codex ') && lo.includes('event')) return true
108
+ if (/^[a-z]+(_[a-z0-9]+){1,4}$/.test(lo) && v.length<=40) return true
109
+ if (/^<[a-zA-Z_][a-zA-Z0-9_]*[\s>]/.test(v.trimStart())) return true
110
+ return false
111
+ }
112
+
113
+ export function truncStr(v, n = 150) {
114
+ v = cleanStr(v)
115
+ return v.length <= n ? v : v.slice(0, n - 1).trimEnd() + '…'
116
+ }
117
+
118
+ export function sidFromPath(p) {
119
+ const name = basename(p)
120
+ const m = name.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i)
121
+ return m ? m[1] : name.replace(/\.[^.]+$/, '')
122
+ }
123
+
124
+ export function dateFromPath(p) {
125
+ const name = basename(p)
126
+ const pats = [
127
+ /(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})/,
128
+ /(\d{4})-(\d{2})-(\d{2})/,
129
+ ]
130
+ for (const pat of pats) {
131
+ const m = name.match(pat)
132
+ if (m) {
133
+ const g = m.slice(1).map(Number)
134
+ try { return new Date(g[0], g[1]-1, g[2], g[3]||0, g[4]||0, g[5]||0) } catch {}
135
+ }
136
+ }
137
+ try { return new Date(statSync(p).mtimeMs) } catch { return new Date(0) }
138
+ }
139
+
140
+ export function fmtDate(d) {
141
+ try { return d.toISOString().slice(0,16).replace('T',' ') } catch { return '' }
142
+ }
143
+
144
+ export function projName(p) {
145
+ if (!p || p === 'Unknown') return 'Unknown Project'
146
+ try { return basename(p) || p } catch { return p }
147
+ }
148
+
149
+ export function ignored(p) {
150
+ return p.split('/').some(part => SKIP_PARTS.has(part))
151
+ }
152
+
153
+ export function groupItems(items) {
154
+ const map = new Map()
155
+ for (const item of items) {
156
+ const k = item.agent + '::' + item.project
157
+ if (!map.has(k)) map.set(k, { agent: item.agent, agentLabel: item.agentLabel, project: item.project, projectPath: item.projectPath, items: [], totalSize: 0, latest: item.date })
158
+ const g = map.get(k)
159
+ g.items.push(item)
160
+ g.totalSize += item.size
161
+ if (item.date > g.latest) g.latest = item.date
162
+ }
163
+ return [...map.values()].sort((a,b) => b.latest - a.latest)
164
+ }
165
+
166
+ // ── Title extraction (port of Python logic) ───────────────────────────────────
167
+
168
+ export function findStrings(obj, keys) {
169
+ const out = []
170
+ if (!obj) return out
171
+ if (typeof obj === 'object' && !Array.isArray(obj)) {
172
+ for (const [k, v] of Object.entries(obj)) {
173
+ if (keys.has(k.toLowerCase())) {
174
+ if (typeof v === 'string') out.push(v)
175
+ else if (Array.isArray(v)) v.forEach(i => { if (typeof i === 'string') out.push(i) })
176
+ }
177
+ if (v && typeof v === 'object') out.push(...findStrings(v, keys))
178
+ }
179
+ } else if (Array.isArray(obj)) {
180
+ obj.forEach(i => out.push(...findStrings(i, keys)))
181
+ }
182
+ return out
183
+ }
184
+
185
+ function extractText(v) {
186
+ if (typeof v === 'string') return [v]
187
+ if (Array.isArray(v)) return v.flatMap(extractText)
188
+ if (v && typeof v === 'object') {
189
+ const out = []
190
+ const typ = cleanStr(v.type || '').toLowerCase()
191
+ if (typeof v.text === 'string') out.push(v.text)
192
+ if (v.content) out.push(...extractText(v.content))
193
+ if (v.message) out.push(...extractText(v.message))
194
+ if (v.input && !['local_shell_call','tool_call','function_call'].includes(typ)) out.push(...extractText(v.input))
195
+ if (v.prompt) out.push(...extractText(v.prompt))
196
+ return out
197
+ }
198
+ return []
199
+ }
200
+
201
+ function isUserObj(obj) {
202
+ if (!obj || typeof obj !== 'object') return false
203
+ const role = cleanStr(obj.role || '').toLowerCase()
204
+ const typ = cleanStr(obj.type || '').toLowerCase()
205
+ const it = cleanStr(obj.item_type || '').toLowerCase()
206
+ if (role === 'user') return true
207
+ if (['user','user_message'].includes(typ)) return true
208
+ if (['user','user_message'].includes(it)) return true
209
+ if (obj.item?.role && cleanStr(obj.item.role).toLowerCase() === 'user') return true
210
+ if (obj.payload?.role && cleanStr(obj.payload.role).toLowerCase() === 'user') return true
211
+ return false
212
+ }
213
+
214
+ function userMessages(obj) {
215
+ const out = []
216
+ if (!obj) return out
217
+ if (typeof obj === 'object' && !Array.isArray(obj)) {
218
+ if (isUserObj(obj)) {
219
+ out.push(...extractText(obj))
220
+ for (const sub of [obj.item, obj.payload]) {
221
+ if (sub && typeof sub === 'object') out.push(...extractText(sub))
222
+ }
223
+ }
224
+ for (const v of Object.values(obj)) out.push(...userMessages(v))
225
+ } else if (Array.isArray(obj)) {
226
+ obj.forEach(i => out.push(...userMessages(i)))
227
+ }
228
+ return out
229
+ }
230
+
231
+ export function titleFromObjs(objs) {
232
+ for (const obj of objs) {
233
+ for (const v of findStrings(obj, TITLE_KEYS)) {
234
+ const t = truncStr(v)
235
+ if (!badTitle(t)) return [t, 'title field']
236
+ }
237
+ }
238
+ for (const obj of objs) {
239
+ for (const v of userMessages(obj)) {
240
+ const t = truncStr(v)
241
+ if (!badTitle(t)) return [t, 'user message']
242
+ }
243
+ }
244
+ return ['', '']
245
+ }
246
+
247
+ export function regexTitle(text) {
248
+ const pats = [
249
+ /"role"\s*:\s*"user".{0,2500}?"text"\s*:\s*"((?:\\.|[^"\\]){4,500})"/s,
250
+ /"type"\s*:\s*"user_message".{0,2500}?"text"\s*:\s*"((?:\\.|[^"\\]){4,500})"/s,
251
+ /"type"\s*:\s*"input_text"\s*,\s*"text"\s*:\s*"((?:\\.|[^"\\]){4,500})"/s,
252
+ ]
253
+ for (const pat of pats) {
254
+ const m = text.match(pat)
255
+ if (m) {
256
+ let raw = m[1]
257
+ try { raw = JSON.parse(`"${raw}"`) } catch {}
258
+ const t = truncStr(raw)
259
+ if (!badTitle(t)) return [t, 'regex']
260
+ }
261
+ }
262
+ return ['', '']
263
+ }
264
+
265
+ export function fileTitle(p) {
266
+ let t = basename(p, extname(p))
267
+ t = t.replace(/^rollout-/, '')
268
+ t = t.replace(/\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-?/g, '')
269
+ t = t.replace(/\d{4}-\d{2}-\d{2}-?/g, '')
270
+ t = t.replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, '')
271
+ t = t.replace(/[_-]/g, ' ').replace(/\s+/g, ' ').trim()
272
+ return t || 'Untitled Chat'
273
+ }
274
+
275
+ export function guessProj(text) {
276
+ const cands = []
277
+ for (const m of text.matchAll(/\/(?:Users|home|Volumes)\/[^\s"'\n\r\t]+/g)) {
278
+ const s = m[0].replace(/[.,;:)]+$/, '')
279
+ if (s.length > 8) cands.push(s)
280
+ }
281
+ const pref = ['documents','desktop','projects','workspace','work','upwork','repos','code','codes','dev']
282
+ return cands.find(c => pref.some(w => c.toLowerCase().includes(w))) || cands[0] || ''
283
+ }