claudecode-history-viewer 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,125 @@
1
+ import fs from 'fs'
2
+ import { searchMessages, dedupeHitsByMessage } from './search-utils.js'
3
+
4
+ /** 从 Cursor 用户消息中提取可读文本 */
5
+ function extractCursorUserText(content) {
6
+ let text = ''
7
+ if (typeof content === 'string') text = content
8
+ else if (Array.isArray(content)) {
9
+ text = content
10
+ .filter((c) => c?.type === 'text')
11
+ .map((c) => c.text || '')
12
+ .join('\n')
13
+ }
14
+ text = text.replace(/<timestamp>[\s\S]*?<\/timestamp>\s*/gi, '')
15
+ const m = text.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/i)
16
+ if (m) text = m[1].trim()
17
+ return text.trim()
18
+ }
19
+
20
+ /**
21
+ * 解析 Cursor agent-transcripts JSONL
22
+ * 格式:每行 { role: 'user'|'assistant', message: { content: [...] } }
23
+ */
24
+ export function parseCursorTranscript(filePath) {
25
+ const raw = fs.readFileSync(filePath, 'utf8')
26
+ const lines = raw.split('\n').filter((l) => l.trim())
27
+
28
+ const messages = []
29
+ let title = ''
30
+ let lineIdx = 0
31
+ let assistantRun = null
32
+
33
+ const flushAssistantRun = () => {
34
+ if (!assistantRun) return
35
+ if (assistantRun.text || assistantRun.toolUses.length > 0 || assistantRun.thinking) {
36
+ messages.push(assistantRun)
37
+ }
38
+ assistantRun = null
39
+ }
40
+
41
+ for (const line of lines) {
42
+ lineIdx++
43
+ let row
44
+ try {
45
+ row = JSON.parse(line)
46
+ } catch {
47
+ continue
48
+ }
49
+
50
+ const role = row.role || row.type
51
+ if (!role) continue
52
+
53
+ if (role === 'user') {
54
+ flushAssistantRun()
55
+ const text = extractCursorUserText(row.message?.content)
56
+ if (text) {
57
+ if (!title) title = text.slice(0, 60)
58
+ messages.push({
59
+ id: `cursor-user-${lineIdx}`,
60
+ role: 'user',
61
+ text,
62
+ })
63
+ }
64
+ continue
65
+ }
66
+
67
+ if (role === 'assistant') {
68
+ const content = row.message?.content
69
+ if (!Array.isArray(content)) continue
70
+
71
+ if (!assistantRun) {
72
+ assistantRun = {
73
+ id: `cursor-asst-${lineIdx}`,
74
+ role: 'assistant',
75
+ model: 'Cursor',
76
+ thinking: '',
77
+ text: '',
78
+ toolUses: [],
79
+ }
80
+ }
81
+
82
+ for (const block of content) {
83
+ if (!block || typeof block !== 'object') continue
84
+ if (block.type === 'thinking' && block.thinking) {
85
+ assistantRun.thinking += block.thinking
86
+ }
87
+ if (block.type === 'text' && block.text) {
88
+ assistantRun.text += block.text
89
+ }
90
+ if (block.type === 'tool_use' || block.name) {
91
+ assistantRun.toolUses.push({
92
+ id: block.id || `tool-${lineIdx}-${assistantRun.toolUses.length}`,
93
+ name: block.name || block.type,
94
+ input: block.input || {},
95
+ })
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ flushAssistantRun()
102
+
103
+ return {
104
+ meta: { title, cwd: '', sessionId: '', source: 'cursor' },
105
+ messages: messages.filter((m) => {
106
+ if (m.role === 'assistant') {
107
+ return m.text || m.thinking || m.toolUses?.length > 0
108
+ }
109
+ return true
110
+ }),
111
+ }
112
+ }
113
+
114
+ export function searchInCursorTranscript(filePath, query) {
115
+ if (!query?.trim()) {
116
+ return { matches: [], hits: [], total: 0 }
117
+ }
118
+ const { messages } = parseCursorTranscript(filePath)
119
+ const matches = searchMessages(messages, query)
120
+ return {
121
+ matches,
122
+ hits: dedupeHitsByMessage(matches),
123
+ total: matches.length,
124
+ }
125
+ }
@@ -0,0 +1,208 @@
1
+ import fs from 'fs'
2
+ import { searchMessages, dedupeHitsByMessage } from './search-utils.js'
3
+
4
+ const SKIP_TYPES = new Set([
5
+ 'file-history-snapshot',
6
+ 'permission-mode',
7
+ 'last-prompt',
8
+ 'queue-operation',
9
+ ])
10
+
11
+ /**
12
+ * 解析 JSONL 为前端可渲染的消息列表
13
+ * @returns {{ messages: object[], meta: object }}
14
+ */
15
+ export function parseTranscript(filePath) {
16
+ const raw = fs.readFileSync(filePath, 'utf8')
17
+ const lines = raw.split('\n').filter((l) => l.trim())
18
+
19
+ const messages = []
20
+ const assistantBuffer = new Map() // messageId -> merged assistant
21
+ let title = ''
22
+ let cwd = ''
23
+ let sessionId = ''
24
+
25
+ const flushAssistant = (msgId) => {
26
+ const buf = assistantBuffer.get(msgId)
27
+ if (!buf) return
28
+ messages.push(buf)
29
+ assistantBuffer.delete(msgId)
30
+ }
31
+
32
+ for (const line of lines) {
33
+ let row
34
+ try {
35
+ row = JSON.parse(line)
36
+ } catch {
37
+ continue
38
+ }
39
+
40
+ if (row.sessionId) sessionId = row.sessionId
41
+ if (row.cwd) cwd = row.cwd
42
+ if (row.type === 'ai-title' && row.aiTitle) title = row.aiTitle
43
+
44
+ if (SKIP_TYPES.has(row.type)) continue
45
+
46
+ if (row.type === 'attachment') {
47
+ const att = row.attachment
48
+ if (att?.type === 'skill_listing') continue // 技能列表太长,默认折叠为摘要
49
+ messages.push({
50
+ id: row.uuid,
51
+ role: 'system',
52
+ subtype: 'attachment',
53
+ timestamp: row.timestamp,
54
+ attachmentType: att?.type,
55
+ text: typeof att?.content === 'string' ? att.content : JSON.stringify(att, null, 2),
56
+ })
57
+ continue
58
+ }
59
+
60
+ if (row.type === 'system') {
61
+ if (row.subtype === 'turn_duration') {
62
+ messages.push({
63
+ id: row.uuid,
64
+ role: 'system',
65
+ subtype: 'turn_duration',
66
+ timestamp: row.timestamp,
67
+ durationMs: row.durationMs,
68
+ messageCount: row.messageCount,
69
+ })
70
+ }
71
+ continue
72
+ }
73
+
74
+ if (row.type === 'user') {
75
+ // flush any pending assistants before user turn
76
+ for (const msgId of [...assistantBuffer.keys()]) {
77
+ flushAssistant(msgId)
78
+ }
79
+
80
+ const content = row.message?.content
81
+ const toolResults = []
82
+ let text = ''
83
+
84
+ if (typeof content === 'string') {
85
+ text = content
86
+ } else if (Array.isArray(content)) {
87
+ for (const block of content) {
88
+ if (block?.type === 'text') text += (block.text || '') + '\n'
89
+ if (block?.type === 'tool_result') {
90
+ toolResults.push({
91
+ toolUseId: block.tool_use_id,
92
+ content: normalizeToolResultContent(block.content),
93
+ isError: !!block.is_error,
94
+ })
95
+ }
96
+ }
97
+ text = text.trim()
98
+ }
99
+
100
+ if (toolResults.length > 0) {
101
+ for (const tr of toolResults) {
102
+ messages.push({
103
+ id: `${row.uuid}-${tr.toolUseId}`,
104
+ role: 'tool_result',
105
+ timestamp: row.timestamp,
106
+ toolUseId: tr.toolUseId,
107
+ content: tr.content,
108
+ isError: tr.isError,
109
+ })
110
+ }
111
+ }
112
+
113
+ if (text) {
114
+ messages.push({
115
+ id: row.uuid,
116
+ role: 'user',
117
+ timestamp: row.timestamp,
118
+ text,
119
+ })
120
+ }
121
+ continue
122
+ }
123
+
124
+ if (row.type === 'assistant') {
125
+ const msg = row.message
126
+ if (!msg) continue
127
+
128
+ const msgId = msg.id || row.uuid
129
+ let buf = assistantBuffer.get(msgId)
130
+ if (!buf) {
131
+ buf = {
132
+ id: row.uuid,
133
+ messageId: msgId,
134
+ role: 'assistant',
135
+ timestamp: row.timestamp,
136
+ model: msg.model,
137
+ thinking: '',
138
+ text: '',
139
+ toolUses: [],
140
+ }
141
+ assistantBuffer.set(msgId, buf)
142
+ }
143
+
144
+ if (row.timestamp) buf.timestamp = row.timestamp
145
+ if (msg.model) buf.model = msg.model
146
+
147
+ for (const block of msg.content || []) {
148
+ if (!block || typeof block !== 'object') continue
149
+ if (block.type === 'thinking' && block.thinking) {
150
+ buf.thinking += block.thinking
151
+ }
152
+ if (block.type === 'text' && block.text) {
153
+ buf.text += block.text
154
+ }
155
+ if (block.type === 'tool_use') {
156
+ buf.toolUses.push({
157
+ id: block.id,
158
+ name: block.name,
159
+ input: block.input,
160
+ })
161
+ }
162
+ }
163
+ }
164
+ }
165
+
166
+ // flush remaining assistants
167
+ for (const msgId of [...assistantBuffer.keys()]) {
168
+ flushAssistant(msgId)
169
+ }
170
+
171
+ return {
172
+ meta: { sessionId, title, cwd },
173
+ messages: messages.filter((m) => {
174
+ if (m.role === 'assistant') {
175
+ return m.text || m.thinking || (m.toolUses && m.toolUses.length > 0)
176
+ }
177
+ return true
178
+ }),
179
+ }
180
+ }
181
+
182
+ function normalizeToolResultContent(content) {
183
+ if (typeof content === 'string') return content
184
+ if (Array.isArray(content)) {
185
+ return content
186
+ .map((c) => {
187
+ if (typeof c === 'string') return c
188
+ if (c?.type === 'text') return c.text
189
+ return JSON.stringify(c)
190
+ })
191
+ .join('\n')
192
+ }
193
+ return JSON.stringify(content, null, 2)
194
+ }
195
+
196
+ /** 在会话 transcript 中全文搜索 */
197
+ export function searchInTranscript(filePath, query) {
198
+ if (!query?.trim()) {
199
+ return { matches: [], hits: [], total: 0 }
200
+ }
201
+ const { messages } = parseTranscript(filePath)
202
+ const matches = searchMessages(messages, query)
203
+ return {
204
+ matches,
205
+ hits: dedupeHitsByMessage(matches),
206
+ total: matches.length,
207
+ }
208
+ }
@@ -0,0 +1,177 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import os from 'os'
4
+ import { decodeProjectSlug } from './scanner.js'
5
+
6
+ const CURSOR_DIR = path.join(os.homedir(), '.cursor')
7
+ const PROJECTS_DIR = path.join(CURSOR_DIR, 'projects')
8
+ const SOURCE = 'cursor'
9
+ const ID_PREFIX = 'cursor::'
10
+
11
+ export function cursorSessionId(uuid) {
12
+ return `${ID_PREFIX}${uuid}`
13
+ }
14
+
15
+ export function parseCursorSessionId(id) {
16
+ if (!id.startsWith(ID_PREFIX)) return null
17
+ const rest = id.slice(ID_PREFIX.length)
18
+ if (rest.includes('::sub::')) {
19
+ const [parent, agent] = rest.split('::sub::')
20
+ return { uuid: parent, parentSessionId: cursorSessionId(parent), agentId: agent, kind: 'subagent' }
21
+ }
22
+ return { uuid: rest, kind: 'main' }
23
+ }
24
+
25
+ function statSafe(filePath) {
26
+ try {
27
+ return fs.statSync(filePath)
28
+ } catch {
29
+ return null
30
+ }
31
+ }
32
+
33
+ function extractPreview(filePath) {
34
+ let title = ''
35
+ let text = ''
36
+ let messageCount = 0
37
+
38
+ try {
39
+ const content = fs.readFileSync(filePath, 'utf8')
40
+ for (const line of content.split('\n').slice(0, 100)) {
41
+ if (!line.trim()) continue
42
+ try {
43
+ const row = JSON.parse(line)
44
+ const role = row.role || row.type
45
+ if (role === 'user' && row.message?.content) {
46
+ messageCount++
47
+ const c = row.message.content
48
+ let t = ''
49
+ if (typeof c === 'string') t = c
50
+ else if (Array.isArray(c)) {
51
+ t = c.filter((x) => x?.type === 'text').map((x) => x.text).join('')
52
+ }
53
+ const m = t.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/i)
54
+ if (m) t = m[1]
55
+ t = t.replace(/<timestamp>[\s\S]*?<\/timestamp>/gi, '').trim()
56
+ if (t && !text) text = t.slice(0, 120)
57
+ if (t && !title) title = t.slice(0, 60)
58
+ }
59
+ if (role === 'assistant') messageCount++
60
+ } catch {
61
+ /* skip */
62
+ }
63
+ }
64
+ for (const line of content.split('\n')) {
65
+ if (!line.trim()) continue
66
+ try {
67
+ const row = JSON.parse(line)
68
+ if (row.role === 'user' || row.role === 'assistant') messageCount++
69
+ } catch {
70
+ /* skip */
71
+ }
72
+ }
73
+ } catch {
74
+ /* ignore */
75
+ }
76
+
77
+ return { title, text, messageCount }
78
+ }
79
+
80
+ function decodeCursorProjectSlug(slug) {
81
+ if (/^\d+$/.test(slug)) return `Cursor 工作区 #${slug}`
82
+ return decodeProjectSlug(slug)
83
+ }
84
+
85
+ function buildRecord({
86
+ id,
87
+ projectSlug,
88
+ filePath,
89
+ kind,
90
+ parentSessionId,
91
+ stat,
92
+ }) {
93
+ const preview = extractPreview(filePath)
94
+ return {
95
+ id,
96
+ source: SOURCE,
97
+ kind,
98
+ parentSessionId,
99
+ projectSlug,
100
+ projectPath: decodeCursorProjectSlug(projectSlug),
101
+ filePath,
102
+ title: preview.title || (kind === 'subagent' ? 'Sub-agent' : '未命名会话'),
103
+ preview: preview.text,
104
+ cwd: decodeCursorProjectSlug(projectSlug),
105
+ startedAt: stat?.birthtimeMs,
106
+ updatedAt: stat?.mtimeMs,
107
+ messageCount: preview.messageCount,
108
+ agentType: kind === 'subagent' ? 'cursor-subagent' : null,
109
+ agentDescription: null,
110
+ }
111
+ }
112
+
113
+ function scanCursorSubagents(projectSlug, parentUuid, transcriptsDir) {
114
+ const subDir = path.join(transcriptsDir, parentUuid, 'subagents')
115
+ if (!fs.existsSync(subDir)) return []
116
+
117
+ const list = []
118
+ for (const entry of fs.readdirSync(subDir)) {
119
+ if (!entry.endsWith('.jsonl')) continue
120
+ const filePath = path.join(subDir, entry)
121
+ const stat = statSafe(filePath)
122
+ if (!stat) continue
123
+
124
+ const agentId = entry.replace(/\.jsonl$/, '')
125
+ const parentId = cursorSessionId(parentUuid)
126
+ list.push(
127
+ buildRecord({
128
+ id: `${parentId}::sub::${agentId}`,
129
+ projectSlug,
130
+ filePath,
131
+ kind: 'subagent',
132
+ parentSessionId: parentId,
133
+ stat,
134
+ })
135
+ )
136
+ }
137
+ list.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
138
+ return list
139
+ }
140
+
141
+ /** 扫描 ~/.cursor/projects 下 agent-transcripts 目录中的 jsonl */
142
+ export function scanCursorSessions() {
143
+ const sessions = []
144
+ if (!fs.existsSync(PROJECTS_DIR)) return sessions
145
+
146
+ for (const projectSlug of fs.readdirSync(PROJECTS_DIR)) {
147
+ const transcriptsDir = path.join(PROJECTS_DIR, projectSlug, 'agent-transcripts')
148
+ if (!fs.existsSync(transcriptsDir)) continue
149
+
150
+ for (const sessionUuid of fs.readdirSync(transcriptsDir)) {
151
+ const sessionDir = path.join(transcriptsDir, sessionUuid)
152
+ if (!fs.statSync(sessionDir).isDirectory()) continue
153
+
154
+ const mainFile = path.join(sessionDir, `${sessionUuid}.jsonl`)
155
+ if (fs.existsSync(mainFile) && fs.statSync(mainFile).isFile()) {
156
+ const stat = statSafe(mainFile)
157
+ sessions.push(
158
+ buildRecord({
159
+ id: cursorSessionId(sessionUuid),
160
+ projectSlug,
161
+ filePath: mainFile,
162
+ kind: 'main',
163
+ parentSessionId: null,
164
+ stat,
165
+ })
166
+ )
167
+ sessions.push(...scanCursorSubagents(projectSlug, sessionUuid, transcriptsDir))
168
+ }
169
+ }
170
+ }
171
+
172
+ return sessions
173
+ }
174
+
175
+ export function getCursorDir() {
176
+ return CURSOR_DIR
177
+ }