@juspay/shooter 1.16.0 → 1.18.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.
- package/.claude/hooks/codex-hooks.example.json +75 -0
- package/.claude/hooks/notifier.cjs +158 -8
- package/build/client/_app/immutable/assets/{0.DEfoFaGR.css → 0.NV8k8wxG.css} +1 -1
- package/build/client/_app/immutable/assets/0.NV8k8wxG.css.br +0 -0
- package/build/client/_app/immutable/assets/0.NV8k8wxG.css.gz +0 -0
- package/build/client/_app/immutable/chunks/8lO1IL7u.js +1 -0
- package/build/client/_app/immutable/chunks/8lO1IL7u.js.br +0 -0
- package/build/client/_app/immutable/chunks/8lO1IL7u.js.gz +0 -0
- package/build/client/_app/immutable/chunks/B9WQy_3X.js +1 -0
- package/build/client/_app/immutable/chunks/B9WQy_3X.js.br +0 -0
- package/build/client/_app/immutable/chunks/B9WQy_3X.js.gz +0 -0
- package/build/client/_app/immutable/chunks/BdtLzPpO.js +1 -0
- package/build/client/_app/immutable/chunks/BdtLzPpO.js.br +0 -0
- package/build/client/_app/immutable/chunks/BdtLzPpO.js.gz +0 -0
- package/build/client/_app/immutable/chunks/{DlS3abGJ.js → DJvX78LW.js} +1 -1
- package/build/client/_app/immutable/chunks/DJvX78LW.js.br +0 -0
- package/build/client/_app/immutable/chunks/DJvX78LW.js.gz +0 -0
- package/build/client/_app/immutable/chunks/nWG9RHyB.js +3 -0
- package/build/client/_app/immutable/chunks/nWG9RHyB.js.br +0 -0
- package/build/client/_app/immutable/chunks/nWG9RHyB.js.gz +0 -0
- package/build/client/_app/immutable/entry/{app.CSJG7N9H.js → app.f46Ko1hu.js} +2 -2
- package/build/client/_app/immutable/entry/app.f46Ko1hu.js.br +0 -0
- package/build/client/_app/immutable/entry/app.f46Ko1hu.js.gz +0 -0
- package/build/client/_app/immutable/entry/start.BVDjNnXt.js +1 -0
- package/build/client/_app/immutable/entry/start.BVDjNnXt.js.br +2 -0
- package/build/client/_app/immutable/entry/start.BVDjNnXt.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{0.qOL7xtFn.js → 0.D_9EwVmq.js} +1 -1
- package/build/client/_app/immutable/nodes/0.D_9EwVmq.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.D_9EwVmq.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{1.Di708Ago.js → 1.C4eFlqSB.js} +1 -1
- package/build/client/_app/immutable/nodes/1.C4eFlqSB.js.br +0 -0
- package/build/client/_app/immutable/nodes/1.C4eFlqSB.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{2.DSM1znqa.js → 2.CdC092Za.js} +1 -1
- package/build/client/_app/immutable/nodes/2.CdC092Za.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.CdC092Za.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{3.BPa5fh75.js → 3.Dhf4ZWW0.js} +1 -1
- package/build/client/_app/immutable/nodes/3.Dhf4ZWW0.js.br +0 -0
- package/build/client/_app/immutable/nodes/3.Dhf4ZWW0.js.gz +0 -0
- package/build/client/_app/immutable/nodes/6.B3SEB_li.js +1 -0
- package/build/client/_app/immutable/nodes/6.B3SEB_li.js.br +0 -0
- package/build/client/_app/immutable/nodes/6.B3SEB_li.js.gz +0 -0
- package/build/client/_app/immutable/nodes/{7.B7UJd8GQ.js → 7.DV8cJ1lX.js} +3 -3
- package/build/client/_app/immutable/nodes/7.DV8cJ1lX.js.br +0 -0
- package/build/client/_app/immutable/nodes/7.DV8cJ1lX.js.gz +0 -0
- package/build/client/_app/immutable/nodes/8.Bs362gyb.js +2 -0
- package/build/client/_app/immutable/nodes/8.Bs362gyb.js.br +0 -0
- package/build/client/_app/immutable/nodes/8.Bs362gyb.js.gz +0 -0
- package/build/client/_app/immutable/nodes/9.Cf7_3uqT.js +2 -0
- package/build/client/_app/immutable/nodes/9.Cf7_3uqT.js.br +0 -0
- package/build/client/_app/immutable/nodes/9.Cf7_3uqT.js.gz +0 -0
- package/build/client/_app/version.json +1 -1
- package/build/client/_app/version.json.br +0 -0
- package/build/client/_app/version.json.gz +0 -0
- package/build/server/chunks/{0-D8uPamNd.js → 0-Cd7jY0a7.js} +3 -3
- package/build/server/chunks/{0-D8uPamNd.js.map → 0-Cd7jY0a7.js.map} +1 -1
- package/build/server/chunks/{1-DhtioHbs.js → 1-C4BOGoJY.js} +2 -2
- package/build/server/chunks/{1-DhtioHbs.js.map → 1-C4BOGoJY.js.map} +1 -1
- package/build/server/chunks/{2-Cgh7ZFgE.js → 2-Ba0mNwJ6.js} +2 -2
- package/build/server/chunks/{2-Cgh7ZFgE.js.map → 2-Ba0mNwJ6.js.map} +1 -1
- package/build/server/chunks/{3-I6hnjssH.js → 3-Pg8t1uJU.js} +2 -2
- package/build/server/chunks/{3-I6hnjssH.js.map → 3-Pg8t1uJU.js.map} +1 -1
- package/build/server/chunks/{6-bPDbH1_W.js → 6-D8xbnTSo.js} +2 -2
- package/build/server/chunks/6-D8xbnTSo.js.map +1 -0
- package/build/server/chunks/{7-CpBrOkxQ.js → 7-CkVK06S0.js} +2 -2
- package/build/server/chunks/7-CkVK06S0.js.map +1 -0
- package/build/server/chunks/{8-BRGAVfze.js → 8-C8qVhrds.js} +2 -2
- package/build/server/chunks/8-C8qVhrds.js.map +1 -0
- package/build/server/chunks/{9-C6xuAb_Y.js → 9-fL5zqN0T.js} +2 -2
- package/build/server/chunks/9-fL5zqN0T.js.map +1 -0
- package/build/server/chunks/{_server.ts-BrRZXr-8.js → _server.ts-BA_uWcPw.js} +9 -9
- package/build/server/chunks/_server.ts-BA_uWcPw.js.map +1 -0
- package/build/server/chunks/{_server.ts-C6xbNz6d.js → _server.ts-Bu3s5hfv.js} +3 -3
- package/build/server/chunks/{_server.ts-C6xbNz6d.js.map → _server.ts-Bu3s5hfv.js.map} +1 -1
- package/build/server/chunks/{_server.ts-Cq9_scaV.js → _server.ts-CwAjt91u.js} +18 -18
- package/build/server/chunks/_server.ts-CwAjt91u.js.map +1 -0
- package/build/server/chunks/{_server.ts-CFX-S_8q.js → _server.ts-DZ5naqSL.js} +2 -2
- package/build/server/chunks/{_server.ts-CFX-S_8q.js.map → _server.ts-DZ5naqSL.js.map} +1 -1
- package/build/server/chunks/{_server.ts-Dekgb6Hx.js → _server.ts-DZP2lhaY.js} +3 -3
- package/build/server/chunks/{_server.ts-Dekgb6Hx.js.map → _server.ts-DZP2lhaY.js.map} +1 -1
- package/build/server/chunks/_server.ts-DZgfQKiH.js +81 -0
- package/build/server/chunks/_server.ts-DZgfQKiH.js.map +1 -0
- package/build/server/chunks/{_server.ts-CjK0g9dO.js → _server.ts-MbnroWEF.js} +25 -16
- package/build/server/chunks/_server.ts-MbnroWEF.js.map +1 -0
- package/build/server/chunks/{pty-manager-aFpChJah.js → pty-manager-DmNSCKAr.js} +99 -2
- package/build/server/chunks/pty-manager-DmNSCKAr.js.map +1 -0
- package/build/server/chunks/qwen-reader-DGfUbKaJ.js +2112 -0
- package/build/server/chunks/qwen-reader-DGfUbKaJ.js.map +1 -0
- package/build/server/chunks/{_server.ts-D--_NXt2.js → registry-Kcw2UCMv.js} +132 -106
- package/build/server/chunks/registry-Kcw2UCMv.js.map +1 -0
- package/build/server/index.js +1 -1
- package/build/server/index.js.map +1 -1
- package/build/server/manifest.js +16 -16
- package/build/server/manifest.js.map +1 -1
- package/package.json +2 -2
- package/scripts/e2e-all-features.sh +165 -0
- package/scripts/e2e-cross-terminal.sh +168 -0
- package/server.ts +12 -0
- package/src/lib/modules/client/common/index.ts +1 -1
- package/src/lib/modules/client/common/provider.ts +11 -0
- package/src/lib/modules/client/terminal/ChatView.svelte +9 -2
- package/src/lib/modules/client/terminal/LaunchSheet.svelte +4 -0
- package/src/lib/modules/server/sessions/amp-reader.ts +439 -0
- package/src/lib/modules/server/sessions/codex-reader.ts +34 -33
- package/src/lib/modules/server/sessions/copilot-reader.ts +542 -0
- package/src/lib/modules/server/sessions/cursor-reader.ts +634 -0
- package/src/lib/modules/server/sessions/gemini-reader.ts +594 -0
- package/src/lib/modules/server/sessions/opencode-db-path.ts +19 -10
- package/src/lib/modules/server/sessions/opencode-reader.ts +13 -12
- package/src/lib/modules/server/sessions/process-detector.ts +39 -18
- package/src/lib/modules/server/sessions/provider-paths.ts +173 -0
- package/src/lib/modules/server/sessions/qwen-reader.ts +336 -0
- package/src/lib/modules/server/sessions/registry.ts +178 -0
- package/src/lib/modules/server/terminal/codex-watcher.ts +4 -1
- package/src/lib/modules/server/terminal/generic-session-watcher.ts +163 -0
- package/src/lib/modules/server/terminal/pty-manager.ts +51 -0
- package/src/lib/modules/server/ws/session-handler.ts +34 -20
- package/src/lib/theme.css +32 -0
- package/src/lib/types/gemini.ts +100 -0
- package/src/lib/types/generated/Sessions.ts +17 -1
- package/src/lib/types/index.ts +1 -0
- package/src/lib/types/server.ts +23 -6
- package/src/lib/types/sessions.ts +14 -2
- package/src/routes/api/sessions/+server.ts +5 -52
- package/src/routes/api/sessions/connect/+server.ts +18 -11
- package/src/routes/api/terminals/+server.ts +7 -5
- package/src/routes/terminals/+page.svelte +7 -2
- package/src/routes/terminals/[id]/+page.svelte +1 -2
- package/build/client/_app/immutable/assets/0.DEfoFaGR.css.br +0 -0
- package/build/client/_app/immutable/assets/0.DEfoFaGR.css.gz +0 -0
- package/build/client/_app/immutable/chunks/Bkqjn62J.js +0 -1
- package/build/client/_app/immutable/chunks/Bkqjn62J.js.br +0 -1
- package/build/client/_app/immutable/chunks/Bkqjn62J.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DOHhmtDH.js +0 -3
- package/build/client/_app/immutable/chunks/DOHhmtDH.js.br +0 -0
- package/build/client/_app/immutable/chunks/DOHhmtDH.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DlS3abGJ.js.br +0 -0
- package/build/client/_app/immutable/chunks/DlS3abGJ.js.gz +0 -0
- package/build/client/_app/immutable/chunks/Pw0jDB7M.js +0 -1
- package/build/client/_app/immutable/chunks/Pw0jDB7M.js.br +0 -0
- package/build/client/_app/immutable/chunks/Pw0jDB7M.js.gz +0 -0
- package/build/client/_app/immutable/entry/app.CSJG7N9H.js.br +0 -0
- package/build/client/_app/immutable/entry/app.CSJG7N9H.js.gz +0 -0
- package/build/client/_app/immutable/entry/start.CTt1901T.js +0 -1
- package/build/client/_app/immutable/entry/start.CTt1901T.js.br +0 -2
- package/build/client/_app/immutable/entry/start.CTt1901T.js.gz +0 -0
- package/build/client/_app/immutable/nodes/0.qOL7xtFn.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.qOL7xtFn.js.gz +0 -0
- package/build/client/_app/immutable/nodes/1.Di708Ago.js.br +0 -0
- package/build/client/_app/immutable/nodes/1.Di708Ago.js.gz +0 -0
- package/build/client/_app/immutable/nodes/2.DSM1znqa.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.DSM1znqa.js.gz +0 -0
- package/build/client/_app/immutable/nodes/3.BPa5fh75.js.br +0 -0
- package/build/client/_app/immutable/nodes/3.BPa5fh75.js.gz +0 -0
- package/build/client/_app/immutable/nodes/6.B1LwwEF-.js +0 -1
- package/build/client/_app/immutable/nodes/6.B1LwwEF-.js.br +0 -0
- package/build/client/_app/immutable/nodes/6.B1LwwEF-.js.gz +0 -0
- package/build/client/_app/immutable/nodes/7.B7UJd8GQ.js.br +0 -0
- package/build/client/_app/immutable/nodes/7.B7UJd8GQ.js.gz +0 -0
- package/build/client/_app/immutable/nodes/8.CG0mrgBU.js +0 -2
- package/build/client/_app/immutable/nodes/8.CG0mrgBU.js.br +0 -0
- package/build/client/_app/immutable/nodes/8.CG0mrgBU.js.gz +0 -0
- package/build/client/_app/immutable/nodes/9.KwzWaMHj.js +0 -2
- package/build/client/_app/immutable/nodes/9.KwzWaMHj.js.br +0 -0
- package/build/client/_app/immutable/nodes/9.KwzWaMHj.js.gz +0 -0
- package/build/server/chunks/6-bPDbH1_W.js.map +0 -1
- package/build/server/chunks/7-CpBrOkxQ.js.map +0 -1
- package/build/server/chunks/8-BRGAVfze.js.map +0 -1
- package/build/server/chunks/9-C6xuAb_Y.js.map +0 -1
- package/build/server/chunks/_server.ts-BrRZXr-8.js.map +0 -1
- package/build/server/chunks/_server.ts-CjK0g9dO.js.map +0 -1
- package/build/server/chunks/_server.ts-Cq9_scaV.js.map +0 -1
- package/build/server/chunks/_server.ts-D--_NXt2.js.map +0 -1
- package/build/server/chunks/opencode-db-path-CRgzBK5U.js +0 -402
- package/build/server/chunks/opencode-db-path-CRgzBK5U.js.map +0 -1
- package/build/server/chunks/pty-manager-aFpChJah.js.map +0 -1
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor agent session reader — listing + conversation retrieval.
|
|
3
|
+
*
|
|
4
|
+
* Cursor stores agent transcripts at:
|
|
5
|
+
* ~/.cursor/projects/<encoded-project>/agent-transcripts/<uuid>.jsonl (preferred)
|
|
6
|
+
* ~/.cursor/projects/<encoded-project>/agent-transcripts/<uuid>.txt (fallback)
|
|
7
|
+
*
|
|
8
|
+
* The <encoded-project> directory name is the project path with '/' replaced by
|
|
9
|
+
* a separator. We reverse-decode it to recover the original cwd.
|
|
10
|
+
*
|
|
11
|
+
* JSONL format (preferred): one JSON object per line. Real format:
|
|
12
|
+
* { role: 'user'|'assistant', message: { content: string | Block[] } }
|
|
13
|
+
*
|
|
14
|
+
* Block shapes:
|
|
15
|
+
* { type: 'text', text: string }
|
|
16
|
+
* { type: 'thinking', thinking: string }
|
|
17
|
+
* { type: 'tool_use', id: string, name: string, input: object }
|
|
18
|
+
* { type: 'tool_result', tool_use_id: string, content: string|Block[], is_error?: boolean }
|
|
19
|
+
*
|
|
20
|
+
* TXT format (fallback): plain text with 'user:' and 'assistant:' line markers.
|
|
21
|
+
* Lines between markers are collected into a single message.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { ConversationMessage, MessagePart, ProjectGroup, SessionInfo } from '$lib/types';
|
|
25
|
+
|
|
26
|
+
import * as crypto from 'crypto';
|
|
27
|
+
import * as fs from 'fs';
|
|
28
|
+
import { homedir } from 'os';
|
|
29
|
+
import * as path from 'path';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Constants
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const CURSOR_PROJECTS = path.join(homedir(), '.cursor', 'projects');
|
|
36
|
+
const PREFIX_BYTES = 64 * 1024;
|
|
37
|
+
const MAX_FULL_READ_BYTES = 16 * 1024 * 1024;
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Public API
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Return sessions whose transcript file was written within `thresholdMs` of
|
|
45
|
+
* now — i.e. sessions that are currently (or very recently) active.
|
|
46
|
+
*/
|
|
47
|
+
export function detectActiveCursorSessions(
|
|
48
|
+
thresholdMs: number
|
|
49
|
+
): { cwd: string; id: string; startedAt: number }[] {
|
|
50
|
+
const cutoff = Date.now() - thresholdMs;
|
|
51
|
+
const out: { cwd: string; id: string; startedAt: number }[] = [];
|
|
52
|
+
|
|
53
|
+
for (const { encodedDir, filePath } of collectTranscriptFiles()) {
|
|
54
|
+
try {
|
|
55
|
+
const stat = fs.statSync(filePath);
|
|
56
|
+
if (stat.mtimeMs < cutoff) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const id = transcriptId(filePath);
|
|
60
|
+
if (!id) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
out.push({
|
|
64
|
+
cwd: decodeCursorProjectDir(encodedDir),
|
|
65
|
+
id,
|
|
66
|
+
startedAt: stat.birthtimeMs,
|
|
67
|
+
});
|
|
68
|
+
} catch {
|
|
69
|
+
// skip unreadable files
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Return the conversation messages for a given Cursor session ID.
|
|
78
|
+
* Prefers .jsonl; falls back to .txt.
|
|
79
|
+
*/
|
|
80
|
+
export function getCursorConversation(
|
|
81
|
+
sessionId: string,
|
|
82
|
+
offset = 0,
|
|
83
|
+
limit = 200
|
|
84
|
+
): ConversationMessage[] {
|
|
85
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(sessionId)) {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const found = collectTranscriptFiles().find((e) => transcriptId(e.filePath) === sessionId);
|
|
90
|
+
if (!found) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const messages = parseTranscriptFile(found.filePath);
|
|
96
|
+
|
|
97
|
+
if (offset === 0 && messages.length > limit) {
|
|
98
|
+
let startIdx = messages.length - limit;
|
|
99
|
+
while (startIdx > 0 && messages[startIdx]?.role !== 'user') {
|
|
100
|
+
startIdx--;
|
|
101
|
+
}
|
|
102
|
+
return messages.slice(startIdx);
|
|
103
|
+
}
|
|
104
|
+
return messages.slice(offset, offset + limit);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error('[cursor] Failed to read conversation:', error);
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* List all Cursor agent sessions grouped by project, sorted by most-recently-
|
|
113
|
+
* modified first.
|
|
114
|
+
*/
|
|
115
|
+
export function listCursorProjects(): ProjectGroup[] {
|
|
116
|
+
const byCwd = new Map<string, SessionInfo[]>();
|
|
117
|
+
|
|
118
|
+
for (const { encodedDir, filePath } of collectTranscriptFiles()) {
|
|
119
|
+
let stat: fs.Stats;
|
|
120
|
+
try {
|
|
121
|
+
stat = fs.statSync(filePath);
|
|
122
|
+
} catch {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const id = transcriptId(filePath);
|
|
127
|
+
if (!id) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const cwd = decodeCursorProjectDir(encodedDir);
|
|
132
|
+
const title = extractTitle(filePath);
|
|
133
|
+
|
|
134
|
+
const session: SessionInfo = {
|
|
135
|
+
created: stat.birthtime.toISOString(),
|
|
136
|
+
gitBranch: '',
|
|
137
|
+
id,
|
|
138
|
+
messageCount: estimateMessageCount(filePath, stat),
|
|
139
|
+
modified: stat.mtime.toISOString(),
|
|
140
|
+
projectPath: cwd,
|
|
141
|
+
source: 'cursor' as const,
|
|
142
|
+
summary: '',
|
|
143
|
+
title,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const bucket = byCwd.get(cwd);
|
|
147
|
+
if (bucket) {
|
|
148
|
+
bucket.push(session);
|
|
149
|
+
} else {
|
|
150
|
+
byCwd.set(cwd, [session]);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const projects: ProjectGroup[] = [];
|
|
155
|
+
for (const [cwd, sessions] of byCwd) {
|
|
156
|
+
sessions.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
|
157
|
+
const segments = cwd.split('/').filter(Boolean);
|
|
158
|
+
projects.push({
|
|
159
|
+
fullPath: cwd,
|
|
160
|
+
id: shortHash(cwd),
|
|
161
|
+
lastModified: sessions[0]?.modified ?? '',
|
|
162
|
+
name: segments.slice(-2).join('/'),
|
|
163
|
+
sessionCount: sessions.length,
|
|
164
|
+
sessions,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return projects.sort(
|
|
169
|
+
(a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Parse the full conversation from a single transcript file path.
|
|
175
|
+
* No pagination — returns all messages. Returns [] on any error.
|
|
176
|
+
*/
|
|
177
|
+
export function parseCursorSessionFile(filePath: string): ConversationMessage[] {
|
|
178
|
+
try {
|
|
179
|
+
return parseTranscriptFile(filePath);
|
|
180
|
+
} catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Return the absolute transcript file path backing a given sessionId, or null
|
|
187
|
+
* if no matching file exists. Returns null immediately when sessionId fails the
|
|
188
|
+
* same character-safety check used by getCursorConversation.
|
|
189
|
+
*/
|
|
190
|
+
export function resolveCursorSessionFile(sessionId: string): null | string {
|
|
191
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(sessionId)) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
const found = collectTranscriptFiles().find((e) => transcriptId(e.filePath) === sessionId);
|
|
195
|
+
return found ? found.filePath : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Internal helpers
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
/** Collect all agent-transcript files, preferring .jsonl over .txt per stem. */
|
|
203
|
+
function collectTranscriptFiles(): { encodedDir: string; filePath: string }[] {
|
|
204
|
+
const out: { encodedDir: string; filePath: string }[] = [];
|
|
205
|
+
let projectDirs: fs.Dirent[];
|
|
206
|
+
try {
|
|
207
|
+
projectDirs = fs.readdirSync(CURSOR_PROJECTS, { withFileTypes: true });
|
|
208
|
+
} catch {
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const dir of projectDirs) {
|
|
213
|
+
if (!dir.isDirectory()) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const transcriptsDir = path.join(CURSOR_PROJECTS, dir.name, 'agent-transcripts');
|
|
217
|
+
let entries: fs.Dirent[];
|
|
218
|
+
try {
|
|
219
|
+
entries = fs.readdirSync(transcriptsDir, { withFileTypes: true });
|
|
220
|
+
} catch {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Prefer .jsonl; track which stems already have a .jsonl entry.
|
|
225
|
+
const jsonlStems = new Set<string>();
|
|
226
|
+
for (const entry of entries) {
|
|
227
|
+
if (!entry.isFile()) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (entry.name.endsWith('.jsonl')) {
|
|
231
|
+
jsonlStems.add(entry.name.slice(0, -6));
|
|
232
|
+
out.push({ encodedDir: dir.name, filePath: path.join(transcriptsDir, entry.name) });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Add .txt files only when no .jsonl with the same stem exists.
|
|
236
|
+
for (const entry of entries) {
|
|
237
|
+
if (!entry.isFile() || !entry.name.endsWith('.txt')) {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const stem = entry.name.slice(0, -4);
|
|
241
|
+
if (!jsonlStems.has(stem)) {
|
|
242
|
+
out.push({ encodedDir: dir.name, filePath: path.join(transcriptsDir, entry.name) });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Decode a Cursor <encoded-project> dir name back to the original cwd.
|
|
252
|
+
* Cursor replaces '/' with a separator — common candidates are '-' and '%2F'.
|
|
253
|
+
* We try percent-decoding first, then fall back to replacing leading '-' with '/'.
|
|
254
|
+
*/
|
|
255
|
+
function decodeCursorProjectDir(encoded: string): string {
|
|
256
|
+
// Attempt percent-decoding for URL-encoded paths.
|
|
257
|
+
if (encoded.includes('%2F') || encoded.includes('%2f')) {
|
|
258
|
+
try {
|
|
259
|
+
const decoded = decodeURIComponent(encoded.replace(/%2f/gi, '/'));
|
|
260
|
+
if (decoded.startsWith('/')) {
|
|
261
|
+
return decoded;
|
|
262
|
+
}
|
|
263
|
+
} catch {
|
|
264
|
+
// fall through
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Heuristic: if the name starts with a hyphen, the path started with '/'.
|
|
269
|
+
// Replace hyphens with slashes — but only where a likely directory boundary is.
|
|
270
|
+
// This is best-effort; when uncertain we surface the encoded name.
|
|
271
|
+
if (encoded.startsWith('-')) {
|
|
272
|
+
const candidate = encoded.replace(/-/g, '/');
|
|
273
|
+
return candidate;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return encoded;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Rough line-count estimate (avoids reading the full file during listing). */
|
|
280
|
+
function estimateMessageCount(filePath: string, stat: fs.Stats): number {
|
|
281
|
+
const ext = path.extname(filePath);
|
|
282
|
+
const avgBytesPerLine = ext === '.txt' ? 200 : 500;
|
|
283
|
+
return Math.max(1, Math.round(stat.size / avgBytesPerLine));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Extract a title from the first user message in a transcript file.
|
|
288
|
+
* Uses bounded read (PREFIX_BYTES) to avoid loading large files.
|
|
289
|
+
*/
|
|
290
|
+
function extractTitle(filePath: string): string {
|
|
291
|
+
try {
|
|
292
|
+
const prefix = readPrefix(filePath);
|
|
293
|
+
const ext = path.extname(filePath);
|
|
294
|
+
if (ext === '.jsonl') {
|
|
295
|
+
return titleFromJsonl(prefix);
|
|
296
|
+
}
|
|
297
|
+
return titleFromTxt(prefix);
|
|
298
|
+
} catch {
|
|
299
|
+
return 'Untitled Session';
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Narrow an unknown value to a plain object with string keys. */
|
|
304
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
305
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Convert one JSONL line to a ConversationMessage, or null to skip.
|
|
310
|
+
*
|
|
311
|
+
* Real Cursor JSONL format:
|
|
312
|
+
* { role: 'user'|'assistant', message: { content: string | Block[] } }
|
|
313
|
+
*
|
|
314
|
+
* Content can be:
|
|
315
|
+
* - a plain string (user or assistant)
|
|
316
|
+
* - an array of blocks: { type:'text', text } | { type:'thinking', thinking }
|
|
317
|
+
* | { type:'tool_use', id, name, input } | { type:'tool_result', tool_use_id, content, is_error }
|
|
318
|
+
*/
|
|
319
|
+
function jsonlLineToMessage(
|
|
320
|
+
entry: Record<string, unknown>,
|
|
321
|
+
idx: number
|
|
322
|
+
): ConversationMessage | null {
|
|
323
|
+
// Role is always at entry.role (fallback entry.type for older formats).
|
|
324
|
+
const role = resolveRole(entry);
|
|
325
|
+
if (!role) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Content is nested at entry.message.content — entry.message must be an object.
|
|
330
|
+
const messageField = entry.message;
|
|
331
|
+
if (!isRecord(messageField)) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
const rawContent = messageField.content;
|
|
335
|
+
if (rawContent === undefined || rawContent === null) {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const parts: MessagePart[] = [];
|
|
340
|
+
|
|
341
|
+
if (typeof rawContent === 'string') {
|
|
342
|
+
if (!rawContent) {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
parts.push({ content: rawContent, type: 'text' });
|
|
346
|
+
} else if (Array.isArray(rawContent)) {
|
|
347
|
+
for (const block of rawContent as unknown[]) {
|
|
348
|
+
if (!isRecord(block)) {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const blockType = strOf(block, 'type');
|
|
352
|
+
if (blockType === 'text') {
|
|
353
|
+
const text = strOf(block, 'text');
|
|
354
|
+
if (text) {
|
|
355
|
+
parts.push({ content: text, type: 'text' });
|
|
356
|
+
}
|
|
357
|
+
} else if (blockType === 'thinking') {
|
|
358
|
+
const thinking = strOf(block, 'thinking');
|
|
359
|
+
if (thinking) {
|
|
360
|
+
parts.push({ content: thinking, type: 'thinking' });
|
|
361
|
+
}
|
|
362
|
+
} else if (blockType === 'tool_use') {
|
|
363
|
+
const input = block.input;
|
|
364
|
+
parts.push({
|
|
365
|
+
id: strOf(block, 'id'),
|
|
366
|
+
input: isRecord(input) ? input : {},
|
|
367
|
+
toolName: strOf(block, 'name') || 'Unknown',
|
|
368
|
+
type: 'tool_use',
|
|
369
|
+
});
|
|
370
|
+
} else if (blockType === 'tool_result') {
|
|
371
|
+
const resultContent = block.content;
|
|
372
|
+
let output = '';
|
|
373
|
+
if (typeof resultContent === 'string') {
|
|
374
|
+
output = resultContent;
|
|
375
|
+
} else if (Array.isArray(resultContent)) {
|
|
376
|
+
output = (resultContent as unknown[])
|
|
377
|
+
.filter((c): c is Record<string, unknown> => isRecord(c) && c.type === 'text')
|
|
378
|
+
.map((c) => {
|
|
379
|
+
const t = c.text;
|
|
380
|
+
return typeof t === 'string' ? t : '';
|
|
381
|
+
})
|
|
382
|
+
.join('\n');
|
|
383
|
+
}
|
|
384
|
+
const isErr = block.is_error;
|
|
385
|
+
parts.push({
|
|
386
|
+
isError: typeof isErr === 'boolean' ? isErr : false,
|
|
387
|
+
output: output.slice(0, 2000),
|
|
388
|
+
toolUseId: strOf(block, 'tool_use_id'),
|
|
389
|
+
type: 'tool_result',
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
} else {
|
|
394
|
+
// Unexpected content shape — skip.
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (parts.length === 0) {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const id = strOf(entry, 'id') || strOf(entry, 'uuid') || `cursor-${role}-${String(idx)}`;
|
|
403
|
+
const timestamp = strOf(entry, 'timestamp') || strOf(entry, 'createdAt') || '';
|
|
404
|
+
|
|
405
|
+
return { id, parts, role, timestamp };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Parse a Cursor JSONL transcript into ConversationMessage[].
|
|
410
|
+
* Each line: { role: 'user'|'assistant', message: { content: string | Block[] } }
|
|
411
|
+
*/
|
|
412
|
+
function jsonlToMessages(text: string): ConversationMessage[] {
|
|
413
|
+
const messages: ConversationMessage[] = [];
|
|
414
|
+
let idx = 0;
|
|
415
|
+
for (const raw of text.split('\n')) {
|
|
416
|
+
const trimmed = raw.trim();
|
|
417
|
+
if (!trimmed) {
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
const entry = JSON.parse(trimmed) as unknown;
|
|
422
|
+
if (!isRecord(entry)) {
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const msg = jsonlLineToMessage(entry, idx);
|
|
426
|
+
if (msg) {
|
|
427
|
+
messages.push(msg);
|
|
428
|
+
idx++;
|
|
429
|
+
}
|
|
430
|
+
} catch {
|
|
431
|
+
// skip malformed line
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return messages;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Parse a transcript file (JSONL or TXT) into ConversationMessage[]. */
|
|
438
|
+
function parseTranscriptFile(filePath: string): ConversationMessage[] {
|
|
439
|
+
const ext = path.extname(filePath);
|
|
440
|
+
const text = readBounded(filePath);
|
|
441
|
+
if (ext === '.jsonl') {
|
|
442
|
+
return jsonlToMessages(text);
|
|
443
|
+
}
|
|
444
|
+
return parseTxtTranscript(text);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Parse a .txt transcript (user:/assistant: markers) into ConversationMessage[]. */
|
|
448
|
+
function parseTxtTranscript(text: string): ConversationMessage[] {
|
|
449
|
+
const messages: ConversationMessage[] = [];
|
|
450
|
+
let currentRole: 'assistant' | 'user' | null = null;
|
|
451
|
+
const currentLines: string[] = [];
|
|
452
|
+
let idx = 0;
|
|
453
|
+
|
|
454
|
+
const flush = (): void => {
|
|
455
|
+
if (!currentRole || currentLines.length === 0) {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const content = currentLines.join('\n').trim();
|
|
459
|
+
if (content) {
|
|
460
|
+
messages.push({
|
|
461
|
+
id: `cursor-${currentRole}-${String(idx)}`,
|
|
462
|
+
parts: [{ content, type: 'text' }],
|
|
463
|
+
role: currentRole,
|
|
464
|
+
timestamp: '',
|
|
465
|
+
});
|
|
466
|
+
idx++;
|
|
467
|
+
}
|
|
468
|
+
currentLines.length = 0;
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
for (const line of text.split('\n')) {
|
|
472
|
+
if (line.startsWith('user:')) {
|
|
473
|
+
flush();
|
|
474
|
+
currentRole = 'user';
|
|
475
|
+
const rest = line.slice(5).trim();
|
|
476
|
+
if (rest) {
|
|
477
|
+
currentLines.push(rest);
|
|
478
|
+
}
|
|
479
|
+
} else if (line.startsWith('assistant:')) {
|
|
480
|
+
flush();
|
|
481
|
+
currentRole = 'assistant';
|
|
482
|
+
const rest = line.slice(10).trim();
|
|
483
|
+
if (rest) {
|
|
484
|
+
currentLines.push(rest);
|
|
485
|
+
}
|
|
486
|
+
} else if (currentRole) {
|
|
487
|
+
currentLines.push(line);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
flush();
|
|
491
|
+
return messages;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Read a file bounded to MAX_FULL_READ_BYTES (tail for oversized files). */
|
|
495
|
+
function readBounded(filePath: string): string {
|
|
496
|
+
const stat = fs.statSync(filePath);
|
|
497
|
+
if (stat.size <= MAX_FULL_READ_BYTES) {
|
|
498
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
499
|
+
}
|
|
500
|
+
const fd = fs.openSync(filePath, 'r');
|
|
501
|
+
try {
|
|
502
|
+
const start = stat.size - MAX_FULL_READ_BYTES;
|
|
503
|
+
const buf = Buffer.alloc(MAX_FULL_READ_BYTES);
|
|
504
|
+
const n = fs.readSync(fd, buf, 0, MAX_FULL_READ_BYTES, start);
|
|
505
|
+
const tail = buf.toString('utf-8', 0, n);
|
|
506
|
+
// Drop the first (likely partial) line of the tail.
|
|
507
|
+
return tail.slice(tail.indexOf('\n') + 1);
|
|
508
|
+
} finally {
|
|
509
|
+
fs.closeSync(fd);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Read the head of a file (complete lines only), bounded to PREFIX_BYTES. */
|
|
514
|
+
function readPrefix(filePath: string): string {
|
|
515
|
+
const fd = fs.openSync(filePath, 'r');
|
|
516
|
+
try {
|
|
517
|
+
const buf = Buffer.alloc(PREFIX_BYTES);
|
|
518
|
+
const n = fs.readSync(fd, buf, 0, PREFIX_BYTES, 0);
|
|
519
|
+
const text = buf.toString('utf-8', 0, n);
|
|
520
|
+
const lastNl = text.lastIndexOf('\n');
|
|
521
|
+
return lastNl === -1 ? text : text.slice(0, lastNl);
|
|
522
|
+
} finally {
|
|
523
|
+
fs.closeSync(fd);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Resolve the role from a JSONL entry.
|
|
529
|
+
* Accepts `role` or `type` field with values 'user'/'assistant'/'human'/'model'/'bot'.
|
|
530
|
+
*/
|
|
531
|
+
function resolveRole(entry: Record<string, unknown>): 'assistant' | 'user' | null {
|
|
532
|
+
const raw = strOf(entry, 'role') || strOf(entry, 'type');
|
|
533
|
+
switch (raw.toLowerCase()) {
|
|
534
|
+
case 'assistant':
|
|
535
|
+
case 'bot':
|
|
536
|
+
case 'model':
|
|
537
|
+
return 'assistant';
|
|
538
|
+
case 'human':
|
|
539
|
+
case 'user':
|
|
540
|
+
return 'user';
|
|
541
|
+
default:
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function shortHash(input: string): string {
|
|
547
|
+
return crypto.createHash('sha256').update(input).digest('hex').slice(0, 8);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Safely read a string property from a record (returns '' on miss). */
|
|
551
|
+
function strOf(obj: Record<string, unknown>, key: string): string {
|
|
552
|
+
const v = obj[key];
|
|
553
|
+
return typeof v === 'string' ? v : '';
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Extract a title from the prefix of a JSONL transcript. */
|
|
557
|
+
function titleFromJsonl(prefix: string): string {
|
|
558
|
+
for (const raw of prefix.split('\n')) {
|
|
559
|
+
const trimmed = raw.trim();
|
|
560
|
+
if (!trimmed) {
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
try {
|
|
564
|
+
const entry = JSON.parse(trimmed) as unknown;
|
|
565
|
+
if (!isRecord(entry) || resolveRole(entry) !== 'user') {
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
// Real format: entry.message.content (string or array of blocks).
|
|
569
|
+
const messageField = entry.message;
|
|
570
|
+
if (!isRecord(messageField)) {
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
const rawContent = messageField.content;
|
|
574
|
+
let text = '';
|
|
575
|
+
if (typeof rawContent === 'string') {
|
|
576
|
+
text = rawContent;
|
|
577
|
+
} else if (Array.isArray(rawContent)) {
|
|
578
|
+
for (const block of rawContent as unknown[]) {
|
|
579
|
+
if (isRecord(block) && block.type === 'text') {
|
|
580
|
+
const t = block.text;
|
|
581
|
+
if (typeof t === 'string' && t) {
|
|
582
|
+
text = t;
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (text) {
|
|
589
|
+
return truncateTitle(text);
|
|
590
|
+
}
|
|
591
|
+
} catch {
|
|
592
|
+
// skip
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return 'Untitled Session';
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Extract a title from the prefix of a TXT transcript. */
|
|
599
|
+
function titleFromTxt(prefix: string): string {
|
|
600
|
+
for (const line of prefix.split('\n')) {
|
|
601
|
+
if (line.startsWith('user:')) {
|
|
602
|
+
const text = line.slice(5).trim();
|
|
603
|
+
if (text) {
|
|
604
|
+
return truncateTitle(text);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return 'Untitled Session';
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Return the session ID (filename stem) for a transcript path.
|
|
613
|
+
* Validates against /^[A-Za-z0-9_.-]+$/ to prevent traversal.
|
|
614
|
+
*/
|
|
615
|
+
function transcriptId(filePath: string): null | string {
|
|
616
|
+
const base = path.basename(filePath);
|
|
617
|
+
const stem = base.endsWith('.jsonl')
|
|
618
|
+
? base.slice(0, -6)
|
|
619
|
+
: base.endsWith('.txt')
|
|
620
|
+
? base.slice(0, -4)
|
|
621
|
+
: null;
|
|
622
|
+
if (!stem || !/^[A-Za-z0-9_.-]+$/.test(stem)) {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
return stem;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function truncateTitle(text: string): string {
|
|
629
|
+
const first = text.replace(/\s+/g, ' ').trim().split('\n')[0]?.trim() ?? '';
|
|
630
|
+
if (!first) {
|
|
631
|
+
return 'Untitled Session';
|
|
632
|
+
}
|
|
633
|
+
return first.length > 80 ? `${first.slice(0, 77)}...` : first;
|
|
634
|
+
}
|