session-reader.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. /**
  2. * SessionReader - Read OpenCode session data
  3. *
  4. * SIMPLIFIED APPROACH:
  5. * 1. Use SDK client to get session data (primary method)
  6. * 2. Fallback to disk scan by session ID (when SDK unavailable)
  7. *
  8. * This avoids complex path calculations and hash discovery.
  9. * Works for any agent, any project structure.
  10. */
  11. import * as fs from 'fs';
  12. import * as path from 'path';
  13. import * as os from 'os';
  14. import { SessionInfo, Message, Part, MessageWithParts } from '../types/index.js';
  15. // SDK client type (optional dependency)
  16. type OpencodeClient = any;
  17. /**
  18. * Read and parse OpenCode session data
  19. *
  20. * Uses SDK client when available, falls back to simple file scanning.
  21. */
  22. export class SessionReader {
  23. private sdkClient?: OpencodeClient;
  24. private sessionStoragePath: string;
  25. /**
  26. * Create a SessionReader
  27. *
  28. * @param sdkClient - Optional SDK client for retrieving session data
  29. * @param sessionStoragePath - Base storage path (defaults to ~/.local/share/opencode)
  30. */
  31. constructor(sdkClient?: OpencodeClient, sessionStoragePath?: string) {
  32. this.sdkClient = sdkClient;
  33. this.sessionStoragePath = sessionStoragePath || path.join(os.homedir(), '.local', 'share', 'opencode');
  34. }
  35. /**
  36. * Find a session file by scanning all session directories
  37. *
  38. * Simple approach: Just look for the session ID in any hash directory.
  39. * No need to calculate hashes or match project paths.
  40. *
  41. * @param sessionId - Session ID to find
  42. * @returns Full path to session file or null if not found
  43. */
  44. private findSessionFile(sessionId: string): string | null {
  45. try {
  46. const sessionBasePath = path.join(this.sessionStoragePath, 'storage', 'session');
  47. if (!fs.existsSync(sessionBasePath)) {
  48. return null;
  49. }
  50. // Scan all hash directories
  51. const hashDirs = fs.readdirSync(sessionBasePath);
  52. for (const hashDir of hashDirs) {
  53. const hashPath = path.join(sessionBasePath, hashDir);
  54. // Skip if not a directory
  55. if (!fs.statSync(hashPath).isDirectory()) {
  56. continue;
  57. }
  58. // Check if session file exists in this hash directory
  59. const sessionFile = path.join(hashPath, `${sessionId}.json`);
  60. if (fs.existsSync(sessionFile)) {
  61. return sessionFile;
  62. }
  63. }
  64. return null;
  65. } catch (error) {
  66. console.error(`Error finding session file for ${sessionId}:`, error);
  67. return null;
  68. }
  69. }
  70. /**
  71. * Get session metadata
  72. *
  73. * SIMPLIFIED APPROACH:
  74. * 1. Try SDK client first (if available)
  75. * 2. Fallback to scanning disk for session file by ID
  76. *
  77. * No complex path calculations, no hash discovery, no project path matching.
  78. * Just find the session by ID, regardless of where it's stored.
  79. *
  80. * @param sessionId - Session ID to retrieve
  81. * @returns SessionInfo object or null if not found
  82. */
  83. async getSessionInfo(sessionId: string): Promise<SessionInfo | null> {
  84. try {
  85. // Method 1: Use SDK client (preferred - always up to date)
  86. if (this.sdkClient) {
  87. try {
  88. const response = await this.sdkClient.session.get({ path: { id: sessionId } });
  89. if (response.data) {
  90. return response.data as SessionInfo;
  91. }
  92. } catch (error) {
  93. // SDK failed, fall through to disk scan
  94. console.warn(`SDK session.get() failed for ${sessionId}, falling back to disk scan`);
  95. }
  96. }
  97. // Method 2: Scan disk for session file (fallback)
  98. const sessionFile = this.findSessionFile(sessionId);
  99. if (sessionFile) {
  100. const content = fs.readFileSync(sessionFile, 'utf-8');
  101. return JSON.parse(content) as SessionInfo;
  102. }
  103. // Session not found
  104. return null;
  105. } catch (error) {
  106. console.error(`Error reading session info for ${sessionId}:`, error);
  107. return null;
  108. }
  109. }
  110. /**
  111. * List all available sessions
  112. *
  113. * SIMPLIFIED APPROACH:
  114. * 1. Try SDK client first (if available)
  115. * 2. Fallback to scanning all session directories
  116. *
  117. * @returns Array of SessionInfo objects sorted by creation time (newest first)
  118. */
  119. async listSessions(): Promise<SessionInfo[]> {
  120. try {
  121. // Method 1: Use SDK client (preferred)
  122. if (this.sdkClient) {
  123. try {
  124. const response = await this.sdkClient.session.list();
  125. if (response.data) {
  126. return response.data.sort((a: SessionInfo, b: SessionInfo) =>
  127. b.time.created - a.time.created
  128. );
  129. }
  130. } catch (error) {
  131. console.warn('SDK session.list() failed, falling back to disk scan');
  132. }
  133. }
  134. // Method 2: Scan all session directories (fallback)
  135. const sessions: SessionInfo[] = [];
  136. const sessionBasePath = path.join(this.sessionStoragePath, 'storage', 'session');
  137. if (!fs.existsSync(sessionBasePath)) {
  138. return [];
  139. }
  140. // Scan all hash directories
  141. const hashDirs = fs.readdirSync(sessionBasePath);
  142. for (const hashDir of hashDirs) {
  143. const hashPath = path.join(sessionBasePath, hashDir);
  144. if (!fs.statSync(hashPath).isDirectory()) {
  145. continue;
  146. }
  147. // Read all session files in this directory
  148. const files = fs.readdirSync(hashPath).filter(f => f.endsWith('.json'));
  149. for (const file of files) {
  150. const sessionFile = path.join(hashPath, file);
  151. const content = fs.readFileSync(sessionFile, 'utf-8');
  152. const session = JSON.parse(content) as SessionInfo;
  153. sessions.push(session);
  154. }
  155. }
  156. // Sort by creation time (newest first)
  157. return sessions.sort((a, b) => b.time.created - a.time.created);
  158. } catch (error) {
  159. console.error('Error listing sessions:', error);
  160. return [];
  161. }
  162. }
  163. /**
  164. * Get all messages for a session (info only, without parts)
  165. *
  166. * @deprecated Use getMessagesWithParts() instead for full message data
  167. *
  168. * Uses SDK client when available, falls back to disk scan.
  169. *
  170. * @param sessionId - Session ID
  171. * @returns Array of Message objects sorted by creation time
  172. */
  173. async getMessages(sessionId: string): Promise<Message[]> {
  174. const messagesWithParts = await this.getMessagesWithParts(sessionId);
  175. return messagesWithParts.map(m => m.info);
  176. }
  177. /**
  178. * Get all messages for a session WITH their parts included
  179. *
  180. * This is the preferred method as the SDK returns messages with parts embedded.
  181. * Using this avoids the need for separate getParts() calls.
  182. *
  183. * @param sessionId - Session ID
  184. * @returns Array of MessageWithParts objects sorted by creation time
  185. */
  186. async getMessagesWithParts(sessionId: string): Promise<MessageWithParts[]> {
  187. try {
  188. // Method 1: Use SDK client (preferred)
  189. if (this.sdkClient) {
  190. try {
  191. const response = await this.sdkClient.session.messages({ path: { id: sessionId } });
  192. if (response.data) {
  193. // SDK returns { info: Message, parts: Part[] } for each message
  194. return response.data.map((m: any) => ({
  195. info: m.info,
  196. parts: m.parts || [],
  197. }));
  198. }
  199. } catch (error) {
  200. console.warn(`SDK session.messages() failed for ${sessionId}, falling back to disk scan`);
  201. }
  202. }
  203. // Method 2: Scan disk (fallback - not commonly used)
  204. // Note: SDK sessions typically don't have separate message files
  205. return [];
  206. } catch (error) {
  207. console.error(`Error reading messages for session ${sessionId}:`, error);
  208. return [];
  209. }
  210. }
  211. /**
  212. * Get a specific message
  213. *
  214. * Uses SDK client when available.
  215. *
  216. * @param sessionId - Session ID
  217. * @param messageId - Message ID
  218. * @returns Message object or null if not found
  219. */
  220. async getMessage(sessionId: string, messageId: string): Promise<Message | null> {
  221. try {
  222. // Method 1: Use SDK client (preferred)
  223. if (this.sdkClient) {
  224. try {
  225. const response = await this.sdkClient.session.message({
  226. path: { id: sessionId, messageID: messageId }
  227. });
  228. if (response.data) {
  229. return response.data.info;
  230. }
  231. } catch (error) {
  232. console.warn(`SDK session.message() failed for ${messageId}`);
  233. }
  234. }
  235. // Method 2: Disk scan not implemented (SDK sessions don't use separate message files)
  236. return null;
  237. } catch (error) {
  238. console.error(`Error reading message ${messageId}:`, error);
  239. return null;
  240. }
  241. }
  242. /**
  243. * Get all parts for a message
  244. *
  245. * Uses SDK client when available.
  246. *
  247. * @param sessionId - Session ID
  248. * @param messageId - Message ID
  249. * @returns Array of Part objects sorted by creation time
  250. */
  251. async getParts(sessionId: string, messageId: string): Promise<Part[]> {
  252. try {
  253. // Method 1: Use SDK client (preferred)
  254. if (this.sdkClient) {
  255. try {
  256. const response = await this.sdkClient.session.message({
  257. path: { id: sessionId, messageID: messageId }
  258. });
  259. if (response.data && response.data.parts) {
  260. return response.data.parts;
  261. }
  262. } catch (error) {
  263. console.warn(`SDK session.message() failed for parts of ${messageId}`);
  264. }
  265. }
  266. // Method 2: Disk scan not implemented (SDK sessions don't use separate part files)
  267. return [];
  268. } catch (error) {
  269. console.error(`Error reading parts for message ${messageId}:`, error);
  270. return [];
  271. }
  272. }
  273. /**
  274. * Get a specific part
  275. *
  276. * Uses SDK client when available.
  277. *
  278. * @param sessionId - Session ID
  279. * @param messageId - Message ID
  280. * @param partId - Part ID
  281. * @returns Part object or null if not found
  282. */
  283. async getPart(sessionId: string, messageId: string, partId: string): Promise<Part | null> {
  284. try {
  285. // Get all parts and find the specific one
  286. const parts = await this.getParts(sessionId, messageId);
  287. return parts.find(p => p.id === partId) || null;
  288. } catch (error) {
  289. console.error(`Error reading part ${partId}:`, error);
  290. return null;
  291. }
  292. }
  293. /**
  294. * Get complete session data (info + messages + parts)
  295. *
  296. * Retrieves all session data in one call.
  297. *
  298. * @param sessionId - Session ID
  299. * @returns Complete session data
  300. */
  301. async getCompleteSession(sessionId: string): Promise<{
  302. info: SessionInfo | null;
  303. messages: Array<{
  304. message: Message;
  305. parts: Part[];
  306. }>;
  307. }> {
  308. const info = await this.getSessionInfo(sessionId);
  309. const messages = await this.getMessages(sessionId);
  310. const messagesWithParts = await Promise.all(
  311. messages.map(async message => ({
  312. message,
  313. parts: await this.getParts(sessionId, message.id),
  314. }))
  315. );
  316. return {
  317. info,
  318. messages: messagesWithParts,
  319. };
  320. }
  321. }