@recall_v3/mcp-server 0.1.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.
Files changed (50) hide show
  1. package/dist/api/client.d.ts +111 -0
  2. package/dist/api/client.d.ts.map +1 -0
  3. package/dist/api/client.js +244 -0
  4. package/dist/config/index.d.ts +89 -0
  5. package/dist/config/index.d.ts.map +1 -0
  6. package/dist/config/index.js +256 -0
  7. package/dist/crypto/index.d.ts +56 -0
  8. package/dist/crypto/index.d.ts.map +1 -0
  9. package/dist/crypto/index.js +224 -0
  10. package/dist/index.d.ts +10 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +189 -0
  13. package/dist/tools/getContext.d.ts +18 -0
  14. package/dist/tools/getContext.d.ts.map +1 -0
  15. package/dist/tools/getContext.js +87 -0
  16. package/dist/tools/getHistory.d.ts +18 -0
  17. package/dist/tools/getHistory.d.ts.map +1 -0
  18. package/dist/tools/getHistory.js +97 -0
  19. package/dist/tools/getTranscripts.d.ts +19 -0
  20. package/dist/tools/getTranscripts.d.ts.map +1 -0
  21. package/dist/tools/getTranscripts.js +129 -0
  22. package/dist/tools/index.d.ts +13 -0
  23. package/dist/tools/index.d.ts.map +1 -0
  24. package/dist/tools/index.js +37 -0
  25. package/dist/tools/logDecision.d.ts +19 -0
  26. package/dist/tools/logDecision.d.ts.map +1 -0
  27. package/dist/tools/logDecision.js +92 -0
  28. package/dist/tools/saveSession.d.ts +26 -0
  29. package/dist/tools/saveSession.d.ts.map +1 -0
  30. package/dist/tools/saveSession.js +115 -0
  31. package/dist/tools/types.d.ts +32 -0
  32. package/dist/tools/types.d.ts.map +1 -0
  33. package/dist/tools/types.js +33 -0
  34. package/dist/tools/utils.d.ts +52 -0
  35. package/dist/tools/utils.d.ts.map +1 -0
  36. package/dist/tools/utils.js +238 -0
  37. package/package.json +46 -0
  38. package/src/api/client.ts +295 -0
  39. package/src/config/index.ts +247 -0
  40. package/src/crypto/index.ts +207 -0
  41. package/src/index.ts +232 -0
  42. package/src/tools/getContext.ts +106 -0
  43. package/src/tools/getHistory.ts +118 -0
  44. package/src/tools/getTranscripts.ts +150 -0
  45. package/src/tools/index.ts +13 -0
  46. package/src/tools/logDecision.ts +118 -0
  47. package/src/tools/saveSession.ts +159 -0
  48. package/src/tools/types.ts +47 -0
  49. package/src/tools/utils.ts +226 -0
  50. package/tsconfig.json +14 -0
@@ -0,0 +1,150 @@
1
+ /**
2
+ * getTranscripts Tool Implementation
3
+ *
4
+ * Fetches full session transcripts (context.md + history.md + transcripts.md).
5
+ * WARNING: This can be very large and use many tokens.
6
+ * Only use when you need complete historical details.
7
+ */
8
+
9
+ import { RecallApiClient, AuthenticationError, RecallApiError } from '../api/client.js';
10
+ import { getApiBaseUrl, getApiToken, getTeamKey, setTeamKey } from '../config/index.js';
11
+ import { decryptContent } from '../crypto/index.js';
12
+ import { successResponse, errorResponse, formattedResponse, type ToolResponse } from './types.js';
13
+ import { resolveProjectPath, getRepoInfo } from './utils.js';
14
+
15
+ export interface GetTranscriptsArgs {
16
+ projectPath: string;
17
+ }
18
+
19
+ /**
20
+ * Execute the getTranscripts tool
21
+ *
22
+ * @param args - Tool arguments (projectPath is required)
23
+ * @returns MCP tool response with full transcripts content
24
+ */
25
+ export async function getTranscripts(args: GetTranscriptsArgs): Promise<ToolResponse> {
26
+ try {
27
+ // Get API token
28
+ const token = getApiToken();
29
+ if (!token) {
30
+ return errorResponse(
31
+ 'Not authenticated. Run `recall auth` to connect your account, or set RECALL_API_TOKEN environment variable.'
32
+ );
33
+ }
34
+
35
+ // Resolve project path
36
+ const projectPath = resolveProjectPath(args.projectPath);
37
+
38
+ // Get repo info from git
39
+ const repoInfo = await getRepoInfo(projectPath);
40
+ if (!repoInfo) {
41
+ return errorResponse(
42
+ `Could not determine repository info for: ${projectPath}\n` +
43
+ 'Make sure this is a git repository with a remote origin.'
44
+ );
45
+ }
46
+
47
+ // Create API client
48
+ const client = new RecallApiClient({
49
+ baseUrl: getApiBaseUrl(),
50
+ token,
51
+ });
52
+
53
+ // Resolve repo to get repoId and teamId
54
+ const { repoId, teamId } = await client.resolveRepo(repoInfo.fullName, repoInfo.defaultBranch);
55
+
56
+ // Get team key (fetch from API if not cached)
57
+ let teamKey = getTeamKey(teamId);
58
+ if (!teamKey) {
59
+ const keyResponse = await client.getTeamKey(teamId);
60
+ teamKey = keyResponse.encryptionKey;
61
+ setTeamKey(teamId, teamKey);
62
+ }
63
+
64
+ // Fetch transcripts from API
65
+ const response = await client.getTranscripts(repoId);
66
+
67
+ // Decrypt all content
68
+ let contextMd: string;
69
+ let historyMd: string;
70
+ let transcriptsMd: string;
71
+
72
+ try {
73
+ // Decrypt context
74
+ if (response.contextMd.startsWith('{') || response.contextMd.includes(':')) {
75
+ contextMd = decryptContent(response.contextMd, teamKey);
76
+ } else {
77
+ contextMd = response.contextMd;
78
+ }
79
+
80
+ // Decrypt history
81
+ if (response.historyMd.startsWith('{') || response.historyMd.includes(':')) {
82
+ historyMd = decryptContent(response.historyMd, teamKey);
83
+ } else {
84
+ historyMd = response.historyMd;
85
+ }
86
+
87
+ // Decrypt transcripts
88
+ if (response.transcriptsMd.startsWith('{') || response.transcriptsMd.includes(':')) {
89
+ transcriptsMd = decryptContent(response.transcriptsMd, teamKey);
90
+ } else {
91
+ transcriptsMd = response.transcriptsMd;
92
+ }
93
+ } catch (decryptError) {
94
+ // If decryption fails, use as-is
95
+ contextMd = response.contextMd;
96
+ historyMd = response.historyMd;
97
+ transcriptsMd = response.transcriptsMd;
98
+ }
99
+
100
+ // Combine all content with warning
101
+ const combinedContent = [
102
+ `# TOKEN WARNING`,
103
+ ``,
104
+ response.tokenWarning,
105
+ ``,
106
+ `---`,
107
+ ``,
108
+ `# Recall Context`,
109
+ ``,
110
+ contextMd,
111
+ ``,
112
+ `---`,
113
+ ``,
114
+ `# Session History`,
115
+ ``,
116
+ historyMd,
117
+ ``,
118
+ `---`,
119
+ ``,
120
+ `# Full Transcripts`,
121
+ ``,
122
+ transcriptsMd,
123
+ ].join('\n');
124
+
125
+ // Format output
126
+ const header = `Reading full transcripts from: ${projectPath}/.recall`;
127
+ return formattedResponse(header, combinedContent);
128
+
129
+ } catch (error) {
130
+ if (error instanceof AuthenticationError) {
131
+ return errorResponse(
132
+ 'Authentication failed. Your token may have expired.\n' +
133
+ 'Run `recall auth` to reconnect your account.'
134
+ );
135
+ }
136
+
137
+ if (error instanceof RecallApiError) {
138
+ if (error.code === 'REPO_NOT_FOUND') {
139
+ return errorResponse(
140
+ 'This repository is not connected to Recall.\n' +
141
+ 'Run `recall init` to set up team memory for this repo.'
142
+ );
143
+ }
144
+ return errorResponse(`API Error: ${error.message}`);
145
+ }
146
+
147
+ const message = error instanceof Error ? error.message : 'Unknown error';
148
+ return errorResponse(message);
149
+ }
150
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Tool Implementations
3
+ *
4
+ * Exports all MCP tool implementations.
5
+ */
6
+
7
+ export { getContext, type GetContextArgs } from './getContext.js';
8
+ export { getHistory, type GetHistoryArgs } from './getHistory.js';
9
+ export { getTranscripts, type GetTranscriptsArgs } from './getTranscripts.js';
10
+ export { saveSession, type SaveSessionArgs } from './saveSession.js';
11
+ export { logDecision, type LogDecisionArgs } from './logDecision.js';
12
+ export { type ToolResponse, successResponse, errorResponse, formattedResponse } from './types.js';
13
+ export * from './utils.js';
@@ -0,0 +1,118 @@
1
+ /**
2
+ * logDecision Tool Implementation
3
+ *
4
+ * Logs an important decision made during coding.
5
+ * Quick way to capture why something was done.
6
+ */
7
+
8
+ import { RecallApiClient, AuthenticationError, RecallApiError } from '../api/client.js';
9
+ import { getApiBaseUrl, getApiToken, getTeamKey, setTeamKey } from '../config/index.js';
10
+ import { successResponse, errorResponse, type ToolResponse } from './types.js';
11
+ import { resolveProjectPath, getRepoInfo } from './utils.js';
12
+ import type { LogDecisionRequest } from '@recall_v3/shared';
13
+
14
+ export interface LogDecisionArgs {
15
+ decision: string;
16
+ reasoning: string;
17
+ }
18
+
19
+ /**
20
+ * Execute the logDecision tool
21
+ *
22
+ * @param args - Tool arguments with decision and reasoning
23
+ * @returns MCP tool response with confirmation
24
+ */
25
+ export async function logDecision(args: LogDecisionArgs): Promise<ToolResponse> {
26
+ try {
27
+ // Validate required fields
28
+ if (!args.decision || args.decision.trim().length === 0) {
29
+ return errorResponse('Decision is required. Please describe what was decided.');
30
+ }
31
+
32
+ if (!args.reasoning || args.reasoning.trim().length === 0) {
33
+ return errorResponse('Reasoning is required. Please explain why this decision was made.');
34
+ }
35
+
36
+ // Get API token
37
+ const token = getApiToken();
38
+ if (!token) {
39
+ return errorResponse(
40
+ 'Not authenticated. Run `recall auth` to connect your account, or set RECALL_API_TOKEN environment variable.'
41
+ );
42
+ }
43
+
44
+ // Resolve project path (use cwd)
45
+ const projectPath = resolveProjectPath(undefined);
46
+
47
+ // Get repo info from git
48
+ const repoInfo = await getRepoInfo(projectPath);
49
+ if (!repoInfo) {
50
+ return errorResponse(
51
+ `Could not determine repository info for: ${projectPath}\n` +
52
+ 'Make sure this is a git repository with a remote origin.'
53
+ );
54
+ }
55
+
56
+ // Create API client
57
+ const client = new RecallApiClient({
58
+ baseUrl: getApiBaseUrl(),
59
+ token,
60
+ });
61
+
62
+ // Resolve repo to get repoId and teamId
63
+ const { repoId, teamId } = await client.resolveRepo(repoInfo.fullName, repoInfo.defaultBranch);
64
+
65
+ // Get team key (fetch from API if not cached)
66
+ let teamKey = getTeamKey(teamId);
67
+ if (!teamKey) {
68
+ const keyResponse = await client.getTeamKey(teamId);
69
+ teamKey = keyResponse.encryptionKey;
70
+ setTeamKey(teamId, teamKey);
71
+ }
72
+
73
+ // Build the decision request
74
+ const decisionRequest: LogDecisionRequest = {
75
+ decision: args.decision.trim(),
76
+ reasoning: args.reasoning.trim(),
77
+ };
78
+
79
+ // Log decision via API
80
+ const response = await client.logDecision(repoId, decisionRequest);
81
+
82
+ // Build success message
83
+ const successMessage = [
84
+ `Decision logged successfully.`,
85
+ ``,
86
+ `Decision ID: ${response.decisionId}`,
87
+ `Repository: ${repoInfo.fullName}`,
88
+ ``,
89
+ `What: ${args.decision.trim()}`,
90
+ `Why: ${args.reasoning.trim()}`,
91
+ ``,
92
+ `This decision has been added to the team memory.`,
93
+ ].join('\n');
94
+
95
+ return successResponse(successMessage);
96
+
97
+ } catch (error) {
98
+ if (error instanceof AuthenticationError) {
99
+ return errorResponse(
100
+ 'Authentication failed. Your token may have expired.\n' +
101
+ 'Run `recall auth` to reconnect your account.'
102
+ );
103
+ }
104
+
105
+ if (error instanceof RecallApiError) {
106
+ if (error.code === 'REPO_NOT_FOUND') {
107
+ return errorResponse(
108
+ 'This repository is not connected to Recall.\n' +
109
+ 'Run `recall init` to set up team memory for this repo.'
110
+ );
111
+ }
112
+ return errorResponse(`API Error: ${error.message}`);
113
+ }
114
+
115
+ const message = error instanceof Error ? error.message : 'Unknown error';
116
+ return errorResponse(message);
117
+ }
118
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * saveSession Tool Implementation
3
+ *
4
+ * Saves a summary of what was accomplished in this coding session.
5
+ * Updates the team memory files via the Recall API.
6
+ */
7
+
8
+ import { RecallApiClient, AuthenticationError, RecallApiError } from '../api/client.js';
9
+ import { getApiBaseUrl, getApiToken, getTeamKey, setTeamKey } from '../config/index.js';
10
+ import { encryptContent } from '../crypto/index.js';
11
+ import { successResponse, errorResponse, type ToolResponse } from './types.js';
12
+ import { resolveProjectPath, getRepoInfo } from './utils.js';
13
+ import type { SaveSessionRequest } from '@recall_v3/shared';
14
+
15
+ export interface SaveSessionArgs {
16
+ summary: string;
17
+ decisions?: Array<{
18
+ what: string;
19
+ why: string;
20
+ }>;
21
+ mistakes?: string[];
22
+ filesChanged?: string[];
23
+ nextSteps?: string;
24
+ blockers?: string;
25
+ }
26
+
27
+ /**
28
+ * Execute the saveSession tool
29
+ *
30
+ * @param args - Tool arguments with session summary and metadata
31
+ * @returns MCP tool response with save confirmation
32
+ */
33
+ export async function saveSession(args: SaveSessionArgs): Promise<ToolResponse> {
34
+ try {
35
+ // Validate required fields
36
+ if (!args.summary || args.summary.trim().length === 0) {
37
+ return errorResponse('Summary is required. Please provide a description of what was accomplished.');
38
+ }
39
+
40
+ // Get API token
41
+ const token = getApiToken();
42
+ if (!token) {
43
+ return errorResponse(
44
+ 'Not authenticated. Run `recall auth` to connect your account, or set RECALL_API_TOKEN environment variable.'
45
+ );
46
+ }
47
+
48
+ // Resolve project path (use cwd since projectPath is not in args)
49
+ const projectPath = resolveProjectPath(undefined);
50
+
51
+ // Get repo info from git
52
+ const repoInfo = await getRepoInfo(projectPath);
53
+ if (!repoInfo) {
54
+ return errorResponse(
55
+ `Could not determine repository info for: ${projectPath}\n` +
56
+ 'Make sure this is a git repository with a remote origin.'
57
+ );
58
+ }
59
+
60
+ // Create API client
61
+ const client = new RecallApiClient({
62
+ baseUrl: getApiBaseUrl(),
63
+ token,
64
+ });
65
+
66
+ // Resolve repo to get repoId and teamId
67
+ const { repoId, teamId } = await client.resolveRepo(repoInfo.fullName, repoInfo.defaultBranch);
68
+
69
+ // Get team key (fetch from API if not cached)
70
+ let teamKey = getTeamKey(teamId);
71
+ if (!teamKey) {
72
+ const keyResponse = await client.getTeamKey(teamId);
73
+ teamKey = keyResponse.encryptionKey;
74
+ setTeamKey(teamId, teamKey);
75
+ }
76
+
77
+ // Build the session request
78
+ const sessionRequest: SaveSessionRequest = {
79
+ summary: args.summary.trim(),
80
+ };
81
+
82
+ if (args.decisions && args.decisions.length > 0) {
83
+ sessionRequest.decisions = args.decisions;
84
+ }
85
+
86
+ if (args.mistakes && args.mistakes.length > 0) {
87
+ sessionRequest.mistakes = args.mistakes;
88
+ }
89
+
90
+ if (args.filesChanged && args.filesChanged.length > 0) {
91
+ sessionRequest.filesChanged = args.filesChanged;
92
+ }
93
+
94
+ if (args.nextSteps && args.nextSteps.trim().length > 0) {
95
+ sessionRequest.nextSteps = args.nextSteps.trim();
96
+ }
97
+
98
+ if (args.blockers && args.blockers.trim().length > 0) {
99
+ sessionRequest.blockers = args.blockers.trim();
100
+ }
101
+
102
+ // Save session via API
103
+ const response = await client.saveSession(repoId, sessionRequest);
104
+
105
+ // Build success message
106
+ const parts: string[] = [
107
+ `Session saved successfully.`,
108
+ ``,
109
+ `Session ID: ${response.sessionId}`,
110
+ `Repository: ${repoInfo.fullName}`,
111
+ ];
112
+
113
+ if (args.decisions && args.decisions.length > 0) {
114
+ parts.push(`Decisions logged: ${args.decisions.length}`);
115
+ }
116
+
117
+ if (args.mistakes && args.mistakes.length > 0) {
118
+ parts.push(`Mistakes documented: ${args.mistakes.length}`);
119
+ }
120
+
121
+ if (args.filesChanged && args.filesChanged.length > 0) {
122
+ parts.push(`Files tracked: ${args.filesChanged.length}`);
123
+ }
124
+
125
+ if (args.nextSteps) {
126
+ parts.push(`Next steps: ${args.nextSteps}`);
127
+ }
128
+
129
+ if (args.blockers) {
130
+ parts.push(`Blockers: ${args.blockers}`);
131
+ }
132
+
133
+ parts.push(``);
134
+ parts.push(`Team memory has been updated.`);
135
+
136
+ return successResponse(parts.join('\n'));
137
+
138
+ } catch (error) {
139
+ if (error instanceof AuthenticationError) {
140
+ return errorResponse(
141
+ 'Authentication failed. Your token may have expired.\n' +
142
+ 'Run `recall auth` to reconnect your account.'
143
+ );
144
+ }
145
+
146
+ if (error instanceof RecallApiError) {
147
+ if (error.code === 'REPO_NOT_FOUND') {
148
+ return errorResponse(
149
+ 'This repository is not connected to Recall.\n' +
150
+ 'Run `recall init` to set up team memory for this repo.'
151
+ );
152
+ }
153
+ return errorResponse(`API Error: ${error.message}`);
154
+ }
155
+
156
+ const message = error instanceof Error ? error.message : 'Unknown error';
157
+ return errorResponse(message);
158
+ }
159
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * MCP Tool Response Types
3
+ *
4
+ * Shared types for tool implementations.
5
+ */
6
+
7
+ /**
8
+ * Content item in MCP response
9
+ */
10
+ export interface TextContent {
11
+ type: 'text';
12
+ text: string;
13
+ }
14
+
15
+ /**
16
+ * Standard MCP tool response format
17
+ */
18
+ export interface ToolResponse {
19
+ content: TextContent[];
20
+ isError?: boolean;
21
+ }
22
+
23
+ /**
24
+ * Create a success response
25
+ */
26
+ export function successResponse(text: string): ToolResponse {
27
+ return {
28
+ content: [{ type: 'text', text }],
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Create an error response
34
+ */
35
+ export function errorResponse(message: string): ToolResponse {
36
+ return {
37
+ content: [{ type: 'text', text: `Error: ${message}` }],
38
+ isError: true,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Create a formatted response with header and content
44
+ */
45
+ export function formattedResponse(header: string, content: string): ToolResponse {
46
+ return successResponse(`[${header}]\n\n${content}`);
47
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Tool Utilities
3
+ *
4
+ * Shared utilities for tool implementations.
5
+ */
6
+
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import { execSync } from 'node:child_process';
10
+
11
+ /**
12
+ * Repository information extracted from git
13
+ */
14
+ export interface RepoInfo {
15
+ /** Full name like "owner/repo" */
16
+ fullName: string;
17
+ /** Remote URL */
18
+ remoteUrl: string;
19
+ /** Default branch name */
20
+ defaultBranch: string;
21
+ /** Local repo root path */
22
+ localPath: string;
23
+ }
24
+
25
+ /**
26
+ * Resolve project path from argument or cwd
27
+ */
28
+ export function resolveProjectPath(projectPath?: string): string {
29
+ if (projectPath) {
30
+ // Resolve to absolute path
31
+ return path.resolve(projectPath);
32
+ }
33
+
34
+ // Use current working directory
35
+ return process.cwd();
36
+ }
37
+
38
+ /**
39
+ * Find the git root directory for a given path
40
+ */
41
+ export function findGitRoot(startPath: string): string | null {
42
+ let currentPath = path.resolve(startPath);
43
+
44
+ while (currentPath !== path.dirname(currentPath)) {
45
+ if (fs.existsSync(path.join(currentPath, '.git'))) {
46
+ return currentPath;
47
+ }
48
+ currentPath = path.dirname(currentPath);
49
+ }
50
+
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * Execute a git command in the given directory
56
+ */
57
+ function execGit(args: string[], cwd: string): string | null {
58
+ try {
59
+ const result = execSync(`git ${args.join(' ')}`, {
60
+ cwd,
61
+ encoding: 'utf-8',
62
+ stdio: ['pipe', 'pipe', 'pipe'],
63
+ });
64
+ return result.trim();
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Parse GitHub/GitLab remote URL to get owner/repo
72
+ */
73
+ function parseRemoteUrl(url: string): string | null {
74
+ // SSH format: git@github.com:owner/repo.git
75
+ const sshMatch = url.match(/git@[\w.-]+:([\w.-]+\/[\w.-]+?)(?:\.git)?$/);
76
+ if (sshMatch) {
77
+ return sshMatch[1];
78
+ }
79
+
80
+ // HTTPS format: https://github.com/owner/repo.git
81
+ const httpsMatch = url.match(/https?:\/\/[\w.-]+\/([\w.-]+\/[\w.-]+?)(?:\.git)?$/);
82
+ if (httpsMatch) {
83
+ return httpsMatch[1];
84
+ }
85
+
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * Get repository information from git
91
+ */
92
+ export async function getRepoInfo(projectPath: string): Promise<RepoInfo | null> {
93
+ // Find git root
94
+ const gitRoot = findGitRoot(projectPath);
95
+ if (!gitRoot) {
96
+ return null;
97
+ }
98
+
99
+ // Get remote URL
100
+ const remoteUrl = execGit(['config', '--get', 'remote.origin.url'], gitRoot);
101
+ if (!remoteUrl) {
102
+ return null;
103
+ }
104
+
105
+ // Parse full name from URL
106
+ const fullName = parseRemoteUrl(remoteUrl);
107
+ if (!fullName) {
108
+ return null;
109
+ }
110
+
111
+ // Get default branch
112
+ // Try to get from remote HEAD first
113
+ let defaultBranch = execGit(['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'], gitRoot);
114
+ if (defaultBranch) {
115
+ // Remove "origin/" prefix
116
+ defaultBranch = defaultBranch.replace(/^origin\//, '');
117
+ } else {
118
+ // Fallback to common defaults
119
+ defaultBranch = 'main';
120
+ }
121
+
122
+ return {
123
+ fullName,
124
+ remoteUrl,
125
+ defaultBranch,
126
+ localPath: gitRoot,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Read a file if it exists
132
+ */
133
+ export function readFileIfExists(filePath: string): string | null {
134
+ try {
135
+ return fs.readFileSync(filePath, 'utf-8');
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Get the Claude session directory for a project
143
+ * Claude stores sessions in ~/.claude/projects/<hash>/
144
+ */
145
+ export function getClaudeSessionDir(projectPath: string): string | null {
146
+ const claudeDir = path.join(process.env.HOME || '', '.claude', 'projects');
147
+
148
+ if (!fs.existsSync(claudeDir)) {
149
+ return null;
150
+ }
151
+
152
+ // Claude uses a hash of the project path
153
+ // We need to find the matching directory
154
+ try {
155
+ const dirs = fs.readdirSync(claudeDir);
156
+
157
+ for (const dir of dirs) {
158
+ const sessionPath = path.join(claudeDir, dir);
159
+ const stat = fs.statSync(sessionPath);
160
+
161
+ if (!stat.isDirectory()) continue;
162
+
163
+ // Check if this directory's config points to our project
164
+ const configPath = path.join(sessionPath, 'config.json');
165
+ if (fs.existsSync(configPath)) {
166
+ try {
167
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as { projectPath?: string };
168
+ if (config.projectPath === projectPath) {
169
+ return sessionPath;
170
+ }
171
+ } catch {
172
+ // Skip invalid config files
173
+ }
174
+ }
175
+ }
176
+ } catch {
177
+ return null;
178
+ }
179
+
180
+ return null;
181
+ }
182
+
183
+ /**
184
+ * Find the most recent Claude session JSONL file
185
+ */
186
+ export function findLatestSessionFile(projectPath: string): string | null {
187
+ const sessionDir = getClaudeSessionDir(projectPath);
188
+ if (!sessionDir) {
189
+ return null;
190
+ }
191
+
192
+ try {
193
+ const files = fs.readdirSync(sessionDir)
194
+ .filter(f => f.endsWith('.jsonl'))
195
+ .map(f => ({
196
+ name: f,
197
+ path: path.join(sessionDir, f),
198
+ mtime: fs.statSync(path.join(sessionDir, f)).mtime,
199
+ }))
200
+ .sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
201
+
202
+ return files.length > 0 ? files[0].path : null;
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Format bytes to human-readable size
210
+ */
211
+ export function formatBytes(bytes: number): string {
212
+ if (bytes < 1024) return `${bytes} B`;
213
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
214
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
215
+ }
216
+
217
+ /**
218
+ * Format duration in seconds to human-readable
219
+ */
220
+ export function formatDuration(seconds: number): string {
221
+ if (seconds < 60) return `${seconds}s`;
222
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
223
+ const hours = Math.floor(seconds / 3600);
224
+ const mins = Math.floor((seconds % 3600) / 60);
225
+ return `${hours}h ${mins}m`;
226
+ }